LangGraph Persistence Tested: 8 Real Errors
TL;DR: LangGraph persistence is not a "memory switch" you flip on a graph. It writes a state snapshot per step into a table whose primary key is (thread_id, checkpoint_ns, checkpoint_id). I tested langgraph 1.2.16 with langgraph-checkpoint-sqlite 3.1.2 and found three things the official docs do not spell out: SqliteSaver.from_conn_string() returns a context manager, not a saver, so handing it straight to compile() blows up; passing only thread_id + checkpoint_id to update_state() — exactly what the time-travel page shows — raises KeyError: 'checkpoint_ns'; and while the docs tell you to prune old checkpoints, SqliteSaver.prune() in 3.1.2 is a bare raise NotImplementedError.
What I ran, and what "measured" means here
Every number below came out of a real process on this machine. Nothing is copied from a blog post.
| Component | Version | Source |
|---|---|---|
| Python | 3.13.14 | local sys.version |
| langgraph | 1.2.16 | PyPI and GitHub Release langgraph==1.2.16 (published 2026-10-16T22:11:28Z) — both agree |
| langgraph-checkpoint | 4.3.0 | PyPI |
| langgraph-checkpoint-sqlite | 3.1.2 | PyPI |
| langchain-core | 1.6.12 | PyPI |
| OS | Windows 11 | local |
Scope: "measured" means I executed it and recorded the value. Anything describing official behaviour is quoted from official docs and labelled as such. The throughput numbers are single-process, single-threaded, local-file SQLite — not Postgres, and not production concurrency. Versions and dates are as of 2026-10-19; check the official release page before relying on them.
What a checkpointer actually writes to SQLite
Before writing any business logic, I built a one-node graph and opened the database:
import sqlite3
from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.sqlite import SqliteSaver
class S(TypedDict):
n: Annotated[int, operator.add]
def d(s): return {"n": 1}
g = StateGraph(S)
g.add_node("d", d)
g.add_edge(START, "d")
g.add_edge("d", END)
conn = sqlite3.connect("ckpt.sqlite", check_same_thread=False)
app = g.compile(checkpointer=SqliteSaver(conn))
app.invoke({"n": 0}, {"configurable": {"thread_id": "t-1"}})
The table (dumped from sqlite_master):
CREATE TABLE checkpoints (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
checkpoint_id TEXT NOT NULL,
parent_checkpoint_id TEXT,
type TEXT,
checkpoint BLOB,
metadata BLOB,
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
)
Two things matter here. First, the primary key is a triple, not (thread_id, checkpoint_id) — that is where the KeyError below comes from. Second, measured: each invoke adds 3 rows. 100 invokes produced 300 checkpoint rows and 500 write rows; 200 invokes produced 600. A minimal "input → node → end" graph leaves one snapshot per superstep, not one per run.
Resuming across processes is replay, not memory
A common mental model is that enabling a checkpointer makes the graph "remember". What actually happens: the next invoke reads the last checkpoint from the database, uses it as the starting state, and folds new writes through your reducers.
I launched three completely separate Python processes, each running one invoke and exiting:
process #1 -> {'n': 1}
process #2 -> {'n': 2}
process #3 -> {'n': 3}
No shared memory between them — just one SQLite file and one thread_id. That is what the official docs mean by "fault tolerance": process restarts and container rebuilds do not lose state. The flip side is that changing the thread_id cuts the state: without a checkpointer, two consecutive invokes both return {'n': 1}, and the second run cannot see the first.
Human-in-the-loop: interrupt and Command(resume=)
interrupt() parks a node mid-flight until a human answers. Measured behaviour:
def ask(s):
ans = interrupt({"question": "confirm?", "seen": s["v"]})
return {"v": 10 + int(ans)}
app.invoke({"v": 1}, cfg) # result carries one '__interrupt__'
app.get_state(cfg).next # ('ask',) — not finished
app.invoke(Command(resume=5), cfg) # {'v': 16}
The trap: calling interrupt() on a graph without a checkpointer does not raise. It stuffs an Interrupt into the return value and ends, so you never get a second chance to resume. To tell whether you are genuinely parked, inspect get_state(cfg).next and tasks[].interrupts — not whether invoke threw.
Time travel: the official snippet is two characters short
The official time-travel page reads state like this:
config = {"configurable": {"thread_id": "1", "checkpoint_id": "1ef663ba-..."}}
graph.get_state(config)
Measured: get_state() accepts that fine and returns {'n': 1}. Hand the same config to update_state() to fork, and it explodes:
KeyError: 'checkpoint_ns'
The cause is line 432 of langgraph/checkpoint/sqlite/__init__.py, which unconditionally reads config["configurable"]["checkpoint_ns"] — a field nobody thinks to add when hand-writing a config. Add it and it works:
cfg_ok = {"configurable": {"thread_id": "t7", "checkpoint_ns": "", "checkpoint_id": cid}}
app.update_state(cfg_ok, {"n": 100}) # measured: n goes 1 -> 101
My recommendation: do not hand-write it at all. Pass back the config object returned by get_state_history() — it already carries checkpoint_ns (measured as an empty string). After forking, history went from 9 entries to 10 and the original branch survived: time travel forks, it does not overwrite.
Cleanup: the docs say prune, the SQLite backend raises
The official persistence page, under "Checkpoints growing unboundedly", says long conversations accumulate checkpoints and drive up latency and storage cost, and recommends pruning periodically or setting a retention policy — with a PostgresSaver example.
On the SQLite backend:
app.checkpointer.prune(["t7"])
# NotImplementedError (empty message)
In the source, SqliteSaver.prune() has the base-class signature, but the last line of the body is raise NotImplementedError, with no message at all. So the officially recommended cleanup path does not exist for local SQLite development. The alternative that measured as working is delete_thread(), signature (thread_id: str) -> None; after calling it the checkpoints table went from 10 rows to 0.
The performance bill: a checkpointer is not free
Same graph, 100 invokes each:
| Setup | Time for 100 | Throughput |
|---|---|---|
| No checkpointer | 0.121 s | 827.7 invokes/s |
| SqliteSaver (local file) | 1.293 s | 77.3 invokes/s |
About 10.7x. Storage: 100 turns produced 331,776 B (~3.3 KB/turn), 200 turns produced 475,136 B (~2.4 KB/turn) — the file grows in 4 KB pages, so short-run numbers jump around.
Does latency degrade as history grows? Measured 52.17 ms on turn 1, 28.88 ms at turn 25, 29.93 ms at turn 50, 28.32 ms at turn 100, 27.59 ms at turn 200 (the node contains a deliberate 20 ms sleep, so net is roughly 8 ms). No read amplification observed up to 600 checkpoint rows. Rows keep accumulating though, so long-running jobs still need their own retention policy.
Concurrent writes to one thread: no error, lost updates
This is the most dangerous thing I measured. Sixteen threads, released together with a barrier, all invoke the same thread_id:
16 threads in 0.24s | exceptions = 0
history len: 48 # 16 x 3, every one persisted
final n = 1 # expected 16
No exception, all 48 checkpoints written, final state 1 — 15 of 16 updates (93.75%) vanish from the business point of view. They all forked from the same parent checkpoint, and get_state() returns only the newest one. The SQLite backend does no mutual exclusion for you.
The rule is blunt: writes to one thread_id must be serialised. If you need concurrency, use different thread_ids or queue upstream. The docs position the checkpointer as "thread-scoped memory" — the thread *is* the concurrency boundary.
Troubleshooting: 8 real errors and their fixes
Every one of these I triggered and recorded on this machine. None of them is an invented "typical error".
Error one: TypeError: Invalid checkpointer provided. Expected an instance of BaseCheckpointSaver, True, False, or None. Received _GeneratorContextManager.
Cause: SqliteSaver.from_conn_string() is decorated with @contextmanager, so it yields a context manager. Fix: either with SqliteSaver.from_conn_string("ckpt.sqlite") as saver: or build the connection yourself — SqliteSaver(sqlite3.connect(path, check_same_thread=False)). Keep check_same_thread=False; that is what the official implementation passes.
Error two: ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id
Cause: a checkpointer is installed but no thread_id in the config. Fix: always pass {"configurable": {"thread_id": "..."}}. Design thread_id as a business key (session id, job id), not a timestamp.
Error three: langgraph.errors.InvalidUpdateError: At key 'n': Can receive only one value per step. Use an Annotated key to handle multiple values.
Cause: two parallel branches writing the same key that has no reducer. Fix: give the state field a reducer, e.g. Annotated[list, operator.add]. The official explainer lives at docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE.
Error four: RuntimeError: Cannot use Command(resume=...) without checkpointer
Cause: the graph was compiled without a checkpointer but you try to resume an interrupt. Fix: pass a checkpointer at compile time. The nasty part is that interrupt() itself stays silent, so everything looks fine until the resume blows up.
Error five: RuntimeError: checkpointer=True cannot be used for root graphs.
Cause: error one's message lists True as a valid value; passing compile(checkpointer=True) hits this. Fix: root graphs need an instance; True is for subgraphs.
Error six: KeyError: 'checkpoint_ns'
Cause: see the time-travel section — a hand-written config missing checkpoint_ns. Fix: reuse the config returned by get_state_history().
Error seven: NotImplementedError (empty message)
Cause: SqliteSaver.prune() is not implemented. Fix: use delete_thread() per conversation, or delete by checkpoint range with your own SQL.
Error eight: RuntimeError: Called get_config outside of a runnable context
Cause: calling interrupt() outside a graph node. Fix: interrupt() only works inside a node body; for external approval, split it into "interrupt inside the node" plus "Command(resume=) from outside".
FAQ
Q: Checkpointer or store — which one do I want?
A: The official split: a checkpointer stores a single thread's graph state, used for conversation continuity, human-in-the-loop, time travel and fault tolerance; a store holds application-defined data across threads, for user preferences and long-term knowledge. This article measured the former.
Q: Can I run SQLite in production?
A: The official checkpointers page describes langgraph-checkpoint-sqlite as "Ideal for experimentation and local workflows. Needs to be installed separately." Everything here was measured on SQLite — do not extrapolate the throughput or concurrency results to the Postgres backend.
Q: Checkpoint history keeps growing. What now?
A: The docs say prune periodically, but the SQLite backend does not implement it (error seven). delete_thread() measured as working; set a lifecycle per conversation and delete the thread once it is done.
Q: Can two requests write the same thread at once?
A: Better not. Sixteen concurrent threads produced zero exceptions and 93.75% lost updates. Use separate thread_ids or serialise at the application layer.
Q: Why does my program just end after interrupt() instead of waiting for input?
A: Most likely no checkpointer was compiled in, or you only looked at the invoke return value instead of get_state().next. In that state interrupt() does not raise; the failure surfaces later as error four.
What this article does not cover
This was single-machine, single-process SQLite. I did not measure — and make no claim about — the Postgres backend's concurrency or throughput, subgraph behaviour with a non-empty checkpoint_ns, the async AsyncSqliteSaver, or persistence handled automatically by a managed runtime. Consult the official docs for those.
If you want to keep digging, two earlier posts on this site pair well with it: one on execution-layer scheduling and queues (n8n Task Runner architecture: external and queue modes), one on why retrieval returns the wrong chunks (RAG in production: why retrieval is wrong); and for the choice one level up, Fine-tuning vs RAG vs prompting: the 2026 decision answers it on cost. Together they cover the chain from "how an agent runs" to "where its state lives" to "where its knowledge comes from".
Affiliate disclosure: there are no product links in this article body; product links in this site's recommendation box are affiliate links and may earn me a commission. Every figure here comes from my own runs or from official documentation cited inline, and no vendor sponsored this article.
👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference
👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation
📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews
🔗 Recommended Tools
These are carefully selected tools. Using our affiliate links supports us to keep producing quality content: