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).
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:
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
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 entirely | ValueError: 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 omitted | OK |
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
Here is the guard, in
libs/langgraph/langgraph/pregel/main.py on the current main branch:
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:
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:
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 version | Released | config_specs returns | Message renders |
|---|---|---|---|
langgraph 0.0.60 | 2024-05-31 | [CheckpointThreadId, CheckpointThreadTs] | ['thread_id', 'thread_ts'] |
langgraph-checkpoint 2.0.26 | 2025-05-15 | [CheckpointThreadId, CheckpointNS, CheckpointId] | ['thread_id', 'checkpoint_ns', 'checkpoint_id'] |
langgraph-checkpoint 2.1.0 | 2025-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:
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
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.
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:
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:
- 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.
- Wrap the graph. Register a thin class that owns the
configand forwards athread_idderived 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. - 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:
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:
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
The JavaScript port carries the same string, in
dist/pregel/index.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:
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=Truemeans "inherit the parent graph's checkpointer" and is only valid on a subgraph. Compiled as a root graph it raises immediately.KeyError: 'configurable'fromget_state,get_state_historyorupdate_state. These read the config directly and do not go through the guard at all, so an empty{}produces a rawKeyErrorrather than the friendlyValueError. Same root cause, different error.InvalidUpdateError: Can receive only one value per stepis unrelated to persistence entirely. It is two parallel nodes writing one state key with no reducer, covered in the InvalidUpdateError walkthrough.
Debugging checklist
- Did you compile with a checkpointer? If yes, every entry point needs a
config. - Is
thread_idpresent, nested underconfigurable, and a string? - If the list in your error is
[], check your versions. Bumplanggraphto 0.5.0 or later. - 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.
- On
.batch(), count your configs. One config for N inputs is one thread for N runs. - If you got
KeyError: 'thread_id'rather than theValueError, you supplied aconfigurabledict withoutthread_idin 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_specsin the publishedlanggraph-checkpoint0.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
langgraph1.2.11 and@langchain/langgraph1.4.13, September 2026.
Written by
Ren OkabeRen 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.
Related tutorials
LangGraph Checkpointer: Persist and Resume Agent State (2026)
Make a LangGraph agent durable with a checkpointer: attach SqliteSaver in one line, crash the graph on purpose, and resume from disk without re-running the Claude call.
LangGraph InvalidUpdateError: Fix "Can Receive Only One Value Per Step" (2026)
InvalidUpdateError: Can receive only one value per step means two parallel LangGraph nodes wrote the same state key with no reducer. Reproduce it in 15 lines and fix it the right way in Python and TypeScript (2026).
LangGraph Recursion Limit: Fix GraphRecursionError Properly (2026)
GraphRecursionError means you hit LangGraph's 25-superstep recursion limit. Raise it the right way, find the real infinite loop, and exit gracefully with RemainingSteps.