Skip to content

Engineering & Code

LangGraph Checkpoints Part 2: Real Storage with SQLite

10 min read AI · LLM · LangChain

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:

DoublingBytes multiplied by
5 to 10 turns3.04
25 to 50 turns3.67
50 to 100 turns3.82
100 to 200 turns3.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:

TurnsPlain reducer, checkpoint bytesDeltaChannel, checkpoint bytes
114651253
5149377798
104538115947
2522772140442
5083467681349
1003184848163022
20012430183326388

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:

DoublingBytes multiplied by
5 to 10 turns2.05
25 to 50 turns2.01
50 to 100 turns2.00
100 to 200 turns2.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.