Part 1 used InMemorySaver for every example. It holds checkpoints in a Python dictionary, so the process exit deletes them all.
This part swaps in a checkpointer that writes a file, then opens that file and reads it. Everything Part 1 described as a StateSnapshot turns out to be two rows in two tables. The shape of those tables explains most of what goes wrong with checkpoints in production.
Versions are the same as Part 1: langgraph 1.2.11, langgraph-checkpoint 4.2.0, and langchain 1.3.16. This part adds langgraph-checkpoint-sqlite 3.1.1 and pycryptodome 3.23.0.
Swap the Saver
Install the package:
pip install langgraph-checkpoint-sqlite
SqliteSaver takes a sqlite3 connection. Call setup() once to create the tables:
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
connection = sqlite3.connect("checkpoints.db", check_same_thread=False)
checkpointer = SqliteSaver(connection)
checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
check_same_thread=False matters. LangGraph runs nodes on worker threads, and the default sqlite3 connection refuses use from a thread other than the one that opened it.
Run the same two-node graph from Part 1 and nothing changes in the output:
result: {'foo': 'b', 'bar': ['a', 'b']}
checkpoints: 4
file: checkpoints.db is 20480 bytes
Four checkpoints, the same as before. Every method in Part 1 works unchanged, because they all belong to the checkpointer interface.
Open the File
This is the most useful section in the post. The database has two tables:
$ sqlite3 checkpoints.db ".tables"
checkpoints writes
Here is the schema, straight from the file:
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)
);
CREATE TABLE writes (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL,
checkpoint_id TEXT NOT NULL,
task_id TEXT NOT NULL,
idx INTEGER NOT NULL,
channel TEXT NOT NULL,
type TEXT,
value BLOB,
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
);
Read the primary key on checkpoints. It is exactly the three fields Part 1 called the identity of a checkpoint: thread_id, checkpoint_ns, and checkpoint_id. The parent_checkpoint_id column is the parent_config field, stored as an ordinary column.
Here are the four real rows:
thread_id ns id parent type ckpt_bytes meta_bytes
--------- -- ------------- ------------- ------- ---------- ----------
1 1f1a2482-530d msgpack 255 46
1 1f1a2482-5310 1f1a2482-530d msgpack 535 44
1 1f1a2482-5311 1f1a2482-5310 msgpack 686 44
1 1f1a2482-5312 1f1a2482-5311 msgpack 731 44
Each row’s parent is the row above it, and the first has none. That column is the whole of Part 1’s parent chain.
Watch the ckpt_bytes column. The state grows from 255 bytes to 731 bytes across four steps, and the graph only ever appended two short strings. Remember this for the section on storage growth.
The second table holds one row per channel per task:
ckpt task idx channel type bytes
------------- -------- --- ---------------- ------- -----
1f1a2482-530d d75e39ae 0 foo msgpack 1
1f1a2482-530d d75e39ae 1 bar msgpack 1
1f1a2482-530d d75e39ae 2 branch:to:node_a null 0
1f1a2482-5310 44121dcb 0 foo msgpack 2
1f1a2482-5310 44121dcb 1 bar msgpack 3
1f1a2482-5310 44121dcb 2 branch:to:node_b null 0
1f1a2482-5311 30b35f6c 0 foo msgpack 2
1f1a2482-5311 30b35f6c 1 bar msgpack 3
These are the pending writes Part 1 mentioned. As each node finishes, its output lands here immediately, linked to the checkpoint in progress. The branch:to:node_a rows are control channels that record which node runs next.
The two tables serve two purposes. The checkpoints table holds the state at each super-step boundary, which is what you resume from. The writes table holds each node’s output as it lands, which is what saves a parallel step from re-running its successful nodes. Part 3 uses that.
It Survives a Restart
The point of a file is that a second process can read it. Here is turn one and turn two of the same conversation, run as two separate processes:
########## PROCESS 1 ##########
pid=16504 turn=1
checkpoints already on the thread: 0
[tool] lookup_device('dfw-core-01')
question: What site is dfw-core-01 in?
answer: The device **dfw-core-01** is located at the site **TX-ALPHA-3**.
checkpoints now: 5
file: agent.db is 32768 bytes
########## PROCESS 2 ##########
pid=16508 turn=2
checkpoints already on the thread: 5
[tool] change_window('TX-ALPHA-3')
question: What is the change window for it?
answer: The approved maintenance window for the site **TX-ALPHA-3** (where
**dfw-core-01** is located) is **Tuesday 02:00 to 04:00 CST**.
checkpoints now: 10
The two processes ran under different IDs. The second one found five checkpoints already on the thread, read the history, and resolved “it” to TX-ALPHA-3. Nothing in the code changed from Part 1 except the checkpointer.
What a Conversation Costs
Now the part that surprises people. Take a graph with one node that appends a 200-byte string, and run it fifty times on one thread. No model calls, so these numbers measure storage and nothing else.
one appended message is 200 bytes
turns checkpoints writes ckpt bytes file bytes
1 3 3 1465 70048
5 15 15 14937 271928
10 30 30 45381 589168
25 75 75 227721 1639768
50 150 150 834676 3811008
100 300 300 3184848 7106112
200 600 600 12430183 17067728
Two hundred turns appended 40,000 bytes of actual data. The checkpoint blobs hold 12,430,183 bytes, which is three hundred times more.
The cause is in the design. Each checkpoint stores the full value of every state channel. Turn 30 writes all thirty messages, turn 31 writes all thirty-one, and so on. Total storage for N turns therefore grows with N squared rather than with N.
That claim deserves the ratios rather than an assertion. Each doubling of the turn count should multiply the bytes by four under quadratic growth, and by two under linear growth:
| Doubling | Bytes multiplied by |
|---|---|
| 5 to 10 turns | 3.04 |
| 25 to 50 turns | 3.67 |
| 50 to 100 turns | 3.82 |
| 100 to 200 turns | 3.90 |
The ratio climbs toward four and does not reach it. A fixed overhead of about 1,400 bytes per checkpoint sits under every measurement, and that overhead is linear. It dominates at small N and fades as N grows. So the honest statement is that growth approaches quadratic from below, and the earlier rows understate it.
The cost per turn tells the same story more plainly. One turn costs 1,465 bytes, and turn two hundred costs 62,151 bytes on average.
The file bytes column counts the database plus its write-ahead log. SQLite defaults to WAL mode here, so the main file lags behind and superseded pages linger. Measure both files, or measure after you close the connection.
This is the “checkpoints growing unboundedly” problem the LangChain docs name in their troubleshooting section. On a chat thread with real messages it arrives faster than 200 bytes a turn suggests.
DeltaChannel Cuts It Down
DeltaChannel stores the writes rather than the accumulated value, and rebuilds the state by replaying them. It reads a reducer that takes the current value and a batch of writes:
from langgraph.channels.delta import DeltaChannel
def append_all(state, writes):
"""Add a batch of writes to the accumulated list."""
current = list(state) if state else []
for write in writes:
current.extend(write)
return current
class State(TypedDict):
log: Annotated[list[str], DeltaChannel(append_all)]
Same graph, same message, same turn counts:
| Turns | Plain reducer, checkpoint bytes | DeltaChannel, checkpoint bytes |
|---|---|---|
| 1 | 1465 | 1253 |
| 5 | 14937 | 7798 |
| 10 | 45381 | 15947 |
| 25 | 227721 | 40442 |
| 50 | 834676 | 81349 |
| 100 | 3184848 | 163022 |
| 200 | 12430183 | 326388 |
At two hundred turns the delta version stores one thirty-eighth as much, and the final state still holds all two hundred entries.
Run the same doubling test on it and the growth is linear, not approximately so:
| Doubling | Bytes multiplied by |
|---|---|
| 5 to 10 turns | 2.05 |
| 25 to 50 turns | 2.01 |
| 50 to 100 turns | 2.00 |
| 100 to 200 turns | 2.00 |
The cost per turn settles at about 1,630 bytes and stays there, where the plain reducer climbed to 62,151.
Two warnings come with it. DeltaChannel needs langgraph 1.2 or newer and is in beta, so the API and the stored format may change. Reconstruction replays your reducer, so the reducer must be deterministic.
What the Serializer Writes
The type column said msgpack. That is JsonPlusSerializer, the default, which uses ormsgpack with a JSON fallback. It handles LangChain and LangGraph types, datetimes, and enums.
The output is compact and readable enough to recognize:
{'foo': 'b', 'bar': ['a', 'b']} type=msgpack 16 bytes b'\x82\xa3foo\xa1b\xa3bar\x92\xa1a\xa1b'
{'count': 7} type=msgpack 8 bytes b'\x81\xa5count\x07'
You can read foo and bar in those bytes. Remember that for the next section.
Put an object it does not know into your state and the run fails at write time:
TypeError: Type is not msgpack serializable: Device
pickle_fallback=True catches those:
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
graph.compile(
checkpointer=InMemorySaver(serde=JsonPlusSerializer(pickle_fallback=True))
)
type=pickle 78 bytes
round trip: {'device': <__main__.Device object at 0x10c46dd10>}
hostname: dfw-core-01
The type column now reads pickle for that row. Use this to unblock a pandas DataFrame or a similar object. Prefer plain data in your state, because pickle is slower, larger, and unsafe to load from a source you do not control.
Encrypting Checkpoints
The bytes above are plaintext, and a conversation transcript sits in that file. EncryptedSerializer fixes that. It reads an AES key from LANGGRAPH_AES_KEY:
from langgraph.checkpoint.serde.encrypted import EncryptedSerializer
from langgraph.checkpoint.sqlite import SqliteSaver
serde = EncryptedSerializer.from_pycryptodome_aes()
checkpointer = SqliteSaver(connection, serde=serde)
Run the same graph both ways and grep the raw column for a site code that appears in the state:
=== default serializer ===
state reads back as: {'site': 'TX-ALPHA-3', 'notes': ['reboot approved']}
stored type: msgpack
first 60 bytes: b'\x87\xa1v\x04\xa2ts\xd9 2026-08-27T18:52:36.758813+00:00\xa2id\xd9$1f1a2487-876b-'
contains 'TX-ALPHA-3': True
=== EncryptedSerializer ===
state reads back as: {'site': 'TX-ALPHA-3', 'notes': ['reboot approved']}
stored type: msgpack+aes
first 60 bytes: b'Y\x9d\xb5%M\xb8\x94\xdea\xefQ\x0ed\x94\x9a\x8b\xdc\xf7\x7f*\xe14\xd4\x08O\x8a\xa4\xe3\xf8!\x13\xfd\xea\xf7eml\x0c-\xf8o\xbaKka\x00\xfbk!\x06\xd8\x83i\xcd\xa60\x86^\xae\r'
contains 'TX-ALPHA-3': False
The stored type becomes msgpack+aes, the timestamp and the site code disappear from the raw bytes, and the graph still reads its state back correctly. This costs one environment variable. Turn it on for any thread that carries customer data.
Durability Modes
invoke and stream both take a durability argument with three settings. Run a fifty-node graph once per mode:
a 50 node graph, one run per mode
durability seconds checkpoints final count
exit 0.013 1 50
async 0.029 52 50
sync 0.028 52 50
Those figures held across five runs, varying only in the third decimal.
The checkpoint count is the number that matters. exit wrote one checkpoint for a fifty-node graph. It persists only when the run ends, whether that end is success, an error, or an interrupt. Crash in the middle and there is nothing to resume from.
async and sync both wrote fifty-two, one per super-step plus the input. They differ in when the write happens. async writes while the next step runs, so a crash can lose the last checkpoint. sync writes before the next step starts, so it cannot.
I could not measure a time difference between async and sync here. Local SQLite writes are fast enough that the gap disappears into noise. Expect that gap to open up against a network database, and measure it on your own backend rather than trusting this row.
Pick sync when you cannot afford to lose a step. Pick exit for a long batch job that you would restart from the beginning anyway.
Keeping the Database Small
BaseCheckpointSaver declares more methods than any shipped saver implements. Check before you build on one:
after delete_thread('bravo'): alpha=9, charlie=9
copy_thread raises NotImplementedError
prune raises NotImplementedError
delete_for_runs raises NotImplementedError
delete_thread works and removes every checkpoint for a thread. copy_thread, prune, and delete_for_runs exist on the base class and raise NotImplementedError on both SqliteSaver and InMemorySaver at these versions.
So you must write the SQL yourself to trim a thread:
DELETE FROM checkpoints
WHERE thread_id = 'charlie'
AND checkpoint_id NOT IN (
SELECT checkpoint_id FROM checkpoints
WHERE thread_id = 'charlie'
ORDER BY checkpoint_id DESC LIMIT 1
)
after keeping only the newest: alpha=9, charlie=1
Delete matching rows from writes as well, because nothing cascades. Remember that if you trim history, you cannot replay the steps you deleted.
Postgres
Postgres is the recommended production backend, and the code shape matches:
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string("postgresql://...")
checkpointer.setup()
langgraph-checkpoint-postgres is at 3.1.2 and ships PostgresSaver and AsyncPostgresSaver. One documented trap is worth repeating. The thread_id column has a length limit, so keep the value under 255 characters and use a UUID when you need a deterministic identifier.
I did not run Postgres for this series. Every measured number in this post comes from SQLite on one laptop. Treat the storage growth pattern as general, because it follows from the checkpoint design, and re-measure the timings on your own backend.
What Comes Next
You can now write checkpoints to a file and read the rows behind them. You can also encrypt them, control when they get written, and stop them from growing without limit.
Part 3 uses them. It replays an old checkpoint and forks a thread with update_state. It resumes an interrupt with a different answer. It also recovers from a failed node without a repeat of the nodes that succeeded.