Tutorials
Ren Okabe9 min read4 views

LangGraph Checkpointer Requires 'configurable' Keys: The Real Fix (2026)

"Checkpointer requires one or more of the following 'configurable' keys" means you compiled with a checkpointer and invoked without one. The message names three keys and only thread_id works. Measured in Python and TypeScript (2026).

Three keys hanging from a rail against a dark background: the two outer keys are faint and unusable while only the centre key is solid and aligned with a lock below it, evoking a LangGraph error that names three configurable keys when only thread_id actually works.
Three keys hanging from a rail against a dark background: the two outer keys are faint and unusable while only the centre key is solid and aligned with a lock below it, evoking a LangGraph error that names three configurable keys when only thread_id actually works.
On this page

Quick Answer (2026)

ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id means you compiled a LangGraph graph with a checkpointer and then invoked it without a configurable dict. Add one:

python
app.invoke({"messages": [...]}, config={"configurable": {"thread_id": "any-stable-string"}})

That is the whole fix, and every guide on the first page of Google stops there. Two things they leave out are worth more than the fix itself.

The list of three keys is not a list of alternatives. The message says "one or more of the following", which reads as though checkpoint_ns or checkpoint_id would also do. Measured below: they do not. Supplying either one passes the check and then dies a few frames later with a worse error, KeyError: 'thread_id'. Only thread_id works.

The list is not a requirements list at all. In LangGraph before 0.5.0 that text was an f-string that interpolated checkpointer.config_specs, a property of whichever checkpointer you happened to attach. That is why the same error prints three different lists depending on your version, including a literal empty []. The message was reporting what the checkpointer declared, never what the graph needed.

Verified against langgraph 1.2.11 and langgraph-checkpoint 4.2.0 on Python 3.13, and @langchain/langgraph 1.4.13 on Node 24, in September 2026.

What we measured

Python Every row below was run locally against a three-node graph compiled with InMemorySaver(). The only thing that changes between rows is the config argument.

Scroll to see more

config passed to invoke()Result
argument omitted entirelyValueError: Checkpointer requires ...
{}ValueError: Checkpointer requires ...
{"configurable": {}}ValueError: Checkpointer requires ...
{"configurable": {"foo": 1}}KeyError: 'thread_id'
{"configurable": {"checkpoint_ns": ""}}KeyError: 'thread_id'
{"configurable": {"checkpoint_id": "x"}}KeyError: 'thread_id'
{"configurable": {"thread_id": "t1"}}OK
no checkpointer, argument omittedOK

Read rows four to six again. Two of the three keys the error tells you to supply satisfy the guard and then fail, and the failure they produce names no file, no fix and no documentation. If you took the error at its word and reached for checkpoint_ns, you traded a clear error for an opaque one.

the check does not look for the keys it names

GitHub Here is the guard, in libs/langgraph/langgraph/pregel/main.py on the current main branch:

python
if checkpointer and not config.get(CONF):
    raise ValueError(
        "Checkpointer requires one or more of the following 'configurable' "
        "keys: thread_id, checkpoint_ns, checkpoint_id"
    )

CONF is sys.intern("configurable"). So the condition in full is: there is a checkpointer, and config["configurable"] is missing, or empty, or otherwise falsy. No key name appears in the condition. The three names in the message are a string literal that the code never consults.

That single line explains the whole measurement table. {"configurable": {"foo": 1}} is a non-empty dict, so the guard passes. Execution continues until the checkpointer itself tries to read the thread, in langgraph/checkpoint/memory/__init__.py:

python
thread_id: str = config["configurable"]["thread_id"]

There is no .get() and no default. That is where KeyError: 'thread_id' comes from, and it is the line that states the real requirement. One key is mandatory: thread_id. checkpoint_ns and checkpoint_id are optional coordinates within a thread, used to address a subgraph namespace or to rewind to a specific checkpoint. They were never substitutes for it.

why your error prints a different list from everyone else's

Search the exact string and you will find three incompatible versions of it. All three are real, and one mechanism produces all three.

Until LangGraph 0.5.0 the message was an f-string:

python
raise ValueError(
    f"Checkpointer requires one or more of the following 'configurable' keys: {[s.id for s in checkpointer.config_specs]}"
)

It printed the ids from checkpointer.config_specs, a property on BaseCheckpointSaver. Follow that property across published releases and the eras fall out:

Scroll to see more

Package and versionReleasedconfig_specs returnsMessage renders
langgraph 0.0.602024-05-31[CheckpointThreadId, CheckpointThreadTs]['thread_id', 'thread_ts']
langgraph-checkpoint 2.0.262025-05-15[CheckpointThreadId, CheckpointNS, CheckpointId]['thread_id', 'checkpoint_ns', 'checkpoint_id']
langgraph-checkpoint 2.1.02025-06-16[][]

Those are quoted from the published artifacts, not from a changelog. The 2.1.0 release replaced the body of config_specs with a bare return [] and left the docstring in place, where it still sits today in langgraph-checkpoint 4.2.0.

That is the answer to the strangest report in this cluster. langchain-ai/langgraph#5385, filed 7 July 2025, is a user upgrading langgraph-checkpoint from 2.0.26 to 2.1.0 and suddenly getting:

text
ValueError: Checkpointer requires one or more of the following 'configurable' keys: []

Nothing about their code changed. The error's data source had been emptied underneath it, so the message asked them to supply one or more of nothing. It was never a breaking change in MemorySaver; it was a message that had been printing an implementation detail for two years and finally printed an empty one.

LangGraph 0.5.0, released 2025-06-26, replaced the f-string with the static three-key literal. That fixed the symptom on the 26th, ten days after langgraph-checkpoint 2.1.0 created it on the 16th. Anyone who upgraded the checkpoint package during those ten days, or who pinned langgraph below 0.5.0 afterwards, saw []. If that is you, the fix is a version bump, not a code change.

Note what survived the repair: the guard is still not config.get(CONF), unchanged since at least 0.2.0. Only the string was corrected. The message is now always accurate about a set of key names and still silent about which one you actually need.

the fix, and the shape that is easy to get wrong

LangChain thread_id is your conversation identifier. Anything stable and unique per conversation works: a UUID, a chat row id, a user id if one user has one thread.

python
from langgraph.checkpoint.memory import InMemorySaver

app = graph.compile(checkpointer=InMemorySaver())

cfg = {"configurable": {"thread_id": "user-42-session-7"}}
app.invoke({"n": 0}, config=cfg)
app.invoke({"n": 0}, config=cfg)   # continues the same thread

Three details the measurements turned up:

It must be nested under configurable. Or must it? We tested config={"thread_id": "t1"} at the top level and it worked. The reason is in langchain_core.runnables.config.ensure_config, which sweeps unrecognised top-level keys down into configurable:

python
for k, v in config.items():
    if k not in CONFIG_KEYS and v is not None:
        empty["configurable"][k] = v

So the flat form works by accident of a LangChain-core convenience, not by design. It is not the documented shape and nothing promises it will keep working. Use the nested form.

The type is not enforced. {"thread_id": 42} runs without complaint. An integer thread id will be stringified by some backends and used as-is by others, so two threads can collide across storage backends. Pass a string.

Async and streaming take the same argument. ainvoke, astream, stream and batch all raise the identical ValueError, and all accept the same config. There is no async-specific variant of this problem.

when you do not control the call site

The highest-ranked report of this error is not somebody forgetting an argument. It is a Databricks community thread where a graph works in a notebook and then throws the moment it is registered with MLflow "models from code". The same shape appears in CopilotKit and other host frameworks: your graph is invoked by somebody else's serving layer, which has no idea it needs to pass a thread_id.

That is a genuinely different problem and the notebook fix does not apply. You have three options, in increasing order of correctness:

  1. Compile without a checkpointer for the served artifact. If the serving layer already manages history, in-graph persistence is duplicated state, and duplicated state drifts. This is the right answer more often than people expect.
  2. Wrap the graph. Register a thin class that owns the config and forwards a thread_id derived from whatever session identifier the host does give you. This is what the Databricks poster settled on, and what the platform's own agent-authoring interfaces are for.
  3. Bind the config at compile time. graph.compile(checkpointer=saver).with_config({"configurable": {"thread_id": "..."}}) silences the error, but a single hardcoded thread means every caller shares one conversation. Only do this if the id is genuinely computed per request.

Option 3 is the one that shows up in answers online and it is the one that quietly breaks in production, because it fails by mixing users' history together rather than by raising.

the .batch() trap

Silencing the error on .batch() has a failure mode worth naming, because the obvious fix looks like it works.

.batch() with a single config applies that config to every input:

python
app.batch([{"n": 0}, {"n": 5}], config={"configurable": {"thread_id": "shared"}})

That returns [{'n': 1}, {'n': 6}], which looks correct, and it is not. Both runs wrote into one thread. Measured afterwards, get_state on "shared" returns {'n': 1}, the first input's result, and get_state_history holds six interleaved entries from two unrelated runs. The batch output is right and the persisted state is garbage.

Pass a list of configs instead, one per input:

python
app.batch(
    [{"n": 0}, {"n": 5}],
    config=[
        {"configurable": {"thread_id": "a"}},
        {"configurable": {"thread_id": "b"}},
    ],
)

Now a holds {'n': 1} and b holds {'n': 6}, as they should.

TypeScript already fixed this

TypeScript The JavaScript port carries the same string, in dist/pregel/index.js:

js
if (this.checkpointer !== void 0 && this.checkpointer !== false && inputConfig.configurable === void 0)
  throw new Error(`Checkpointer requires one or more of the following "configurable" keys: "thread_id", "checkpoint_ns", "checkpoint_id"`);

You will almost certainly never see it. The line two above it calls ensureLangGraphConfig, which seeds configurable: {} into its base object and only overwrites defined values, so inputConfig.configurable is never undefined by the time the guard runs. Across four probes, omitted config, {}, {configurable: {}} and {configurable: {foo: 1}}, the guard fired zero times.

What you get instead is this, thrown by the checkpointer:

text
Failed to put checkpoint. The passed RunnableConfig is missing a required "thread_id" field in its
"configurable" property. When using a checkpointer, you must pass a "thread_id" so the checkpointer
knows which conversation thread to persist state for.
Example: graph.stream(input, { configurable: { thread_id: "my-thread-id" } })

It names the one key that is required, says why, and shows the call. That is the message the Python side is missing, and the gap is not subtle: Python answers the same mistake with KeyError: 'thread_id' and no context. If you work in both languages, do not assume the Python error is telling you as much as the Node one.

Adjacent errors that are not this error

Three failures land near this one and each has a different cause:

  • RuntimeError: checkpointer=True cannot be used for root graphs. checkpointer=True means "inherit the parent graph's checkpointer" and is only valid on a subgraph. Compiled as a root graph it raises immediately.
  • KeyError: 'configurable' from get_state, get_state_history or update_state. These read the config directly and do not go through the guard at all, so an empty {} produces a raw KeyError rather than the friendly ValueError. Same root cause, different error.
  • InvalidUpdateError: Can receive only one value per step is unrelated to persistence entirely. It is two parallel nodes writing one state key with no reducer, covered in the InvalidUpdateError walkthrough.

Debugging checklist

  1. Did you compile with a checkpointer? If yes, every entry point needs a config.
  2. Is thread_id present, nested under configurable, and a string?
  3. If the list in your error is [], check your versions. Bump langgraph to 0.5.0 or later.
  4. If the caller is a serving framework, stop adding config at the call site and decide whether the served artifact should carry a checkpointer at all.
  5. On .batch(), count your configs. One config for N inputs is one thread for N runs.
  6. If you got KeyError: 'thread_id' rather than the ValueError, you supplied a configurable dict without thread_id in it. The guard passed; the checkpointer did not.

Once persistence is working, the next thing worth reading is how the checkpoint is actually laid out and when to move off InMemorySaver, which is in the checkpointer setup guide. If your graph is timing out rather than failing to start, that is usually GraphRecursionError instead.

Sources

  • LangGraph pregel/main.py, current main branch, the guard quoted in Step 1.
  • BaseCheckpointSaver.config_specs in the published langgraph-checkpoint 0.0.60, 2.0.26, 2.1.0 and 4.2.0 artifacts.
  • Release dates from the langgraph-checkpoint release history on PyPI.
  • langchain-ai/langgraph issue 5385, the [] report.
  • Reproductions run against langgraph 1.2.11 and @langchain/langgraph 1.4.13, September 2026.
R

Written by

Ren Okabe

Ren Okabe builds and debugs LLM agent systems, with a focus on LangGraph orchestration, evals, and production reliability.

Frequently asked questions

What does "Checkpointer requires one or more of the following 'configurable' keys" mean?

You compiled a LangGraph graph with a checkpointer and then invoked it without a configurable dict. The guard in pregel is literally 'if checkpointer and not config.get("configurable")', so it fires whenever configurable is missing or empty. Pass config={"configurable": {"thread_id": "some-id"}} to invoke, stream, ainvoke or batch.

Can I use checkpoint_ns or checkpoint_id instead of thread_id?

No, despite what the message says. Measured on langgraph 1.2.11: supplying only checkpoint_ns or only checkpoint_id satisfies the guard and then fails with KeyError: 'thread_id' inside the checkpointer, because it reads config['configurable']['thread_id'] with no default. Only thread_id is mandatory. The other two are optional coordinates within a thread.

Why does my error say 'configurable' keys: [] with an empty list?

Because langgraph-checkpoint 2.1.0, released 2025-06-16, changed BaseCheckpointSaver.config_specs to return an empty list, and LangGraph before 0.5.0 built this message by interpolating that property. Your code did not change; the message's data source was emptied. Upgrade langgraph to 0.5.0 or later, which replaced the f-string with a static list of key names.

Why do older posts show 'thread_id', 'thread_ts' instead?

Same mechanism, earlier era. In langgraph 0.0.60 the config_specs property returned CheckpointThreadId and CheckpointThreadTs, so the message rendered ['thread_id', 'thread_ts']. thread_ts was later renamed and split into checkpoint_ns and checkpoint_id. The message was always reporting what the checkpointer declared, never what the graph required.

I get this error only when MLflow or another framework serves my graph. How do I fix it?

The serving layer invokes your graph and has no reason to pass a thread_id, so you cannot fix it at the call site. Either compile the served artifact without a checkpointer if the host already manages history, or wrap the graph in a class that derives a thread_id from the host's own session identifier. Avoid binding a single hardcoded thread_id with with_config, because every caller would then share one conversation.

Does passing one config to .batch() work?

It stops the error and corrupts your state. A single config applies to every input, so all runs write into the same thread. Measured with two inputs on one thread_id, the batch output looked correct while get_state returned only the first input's result and the history held six interleaved entries. Pass a list of configs, one per input.