15 KiB
DeltaChannel Checkpointer Guide
Beta —
DeltaChanneland all APIs described here (_DeltaSnapshot,get_delta_channel_history,get_delta_channel_keepset,delta_updates_since_snapshotmetadata) are in beta. The on-disk representation and method signatures may change in future releases.
This guide is for third-party BaseCheckpointSaver authors who need to
support graphs using DeltaChannel. It covers:
- How
DeltaChannelstate is stored across checkpoint tables. - Which saver methods interact with delta channels and what the OSS implementations look like (working references you can copy).
- How to correctly implement cleanup methods (
prune,delete_for_runs,copy_thread) without silently corrupting delta history.
Part I — Background
How DeltaChannel is stored
Unlike regular channels, DeltaChannel does not write its accumulated
value into every checkpoint. Instead:
-
DeltaChannel.checkpoint()always returnsMISSING(libs/langgraph/langgraph/channels/delta.py). The channel's live value is never placed inchannel_valuesby default. -
A full snapshot (
_DeltaSnapshot(value)) is written intochannel_values[k]only when the channel's update count reachessnapshot_frequency(default 1000). This decision is made bydelta_channels_to_snapshot()inlibs/langgraph/langgraph/pregel/_checkpoint.py. -
Between snapshots, the channel's writes are stored as ordinary
checkpoint_writesrows (Postgres) orwritesrows (SQLite), keyed by(thread_id, checkpoint_ns, checkpoint_id, task_id, idx). -
Reconstruction at read time walks the parent chain backward from the target checkpoint, collecting writes, until it finds an ancestor whose
channel_values[ch]is populated (the "seed"). It then replays all collected writes through the channel's reducer on top of that seed.
Storage layout per backend
| Backend | Snapshot blob | Writes | Key structure |
|---|---|---|---|
| Postgres | checkpoint_blobs table; checkpoint JSON has True sentinel |
checkpoint_writes table |
(thread_id, checkpoint_ns, channel, version) for blobs |
| SQLite | Inline in checkpoint blob | writes table |
(thread_id, checkpoint_ns, checkpoint_id, task_id, idx) |
| InMemory | self.blobs dict |
self.writes dict |
Same logical key structure |
The metadata hint
CheckpointMetadata.delta_updates_since_snapshot (dict[str, int]) tracks
how many supersteps have written to each delta channel since the last
snapshot. It resets to 0 when a snapshot fires. This is a hint for the
pregel loop — the source of truth for "is there a snapshot here?" is whether
channel_values[ch] is populated in the checkpoint.
The reconstruction walk
flowchart LR
subgraph parentChain [Parent Chain]
direction RL
HEAD["HEAD checkpoint<br/>(target)"]
P1["Parent 1<br/>writes: [w4]"]
P2["Parent 2<br/>writes: [w3]"]
P3["Parent 3<br/>_DeltaSnapshot(seed)<br/>writes: [w2]"]
P4["Parent 4<br/>writes: [w1]"]
end
HEAD --> P1
P1 --> P2
P2 --> P3
P3 --> P4
subgraph result [History result]
SEED["seed = _DeltaSnapshot(...)"]
WRITES["writes = [w3, w4]<br/>(oldest → newest)"]
end
P3 -.-> SEED
P2 -.-> WRITES
P1 -.-> WRITES
The walk:
- Starts at
target.parent_config(target's ownpending_writesare excluded — they belong to the next super-step). - At each ancestor, collects
pending_writesfor the requested channels. - Stops per-channel when
channel_values[ch]is non-empty (= seed found). - Returns
{"writes": [...], "seed": ...}per channel. If no seed is found (walk reaches root),"seed"is omitted — the consumer treats this as "start empty."
Default implementation:
BaseCheckpointSaver.aget_delta_channel_history at
libs/checkpoint/langgraph/checkpoint/base/__init__.py — one aget_tuple
call per ancestor. Correct but O(chain_length) round-trips.
Part II — Read & Write Surface
These are the saver methods that interact with DeltaChannel. The base
class provides correct defaults; overrides exist only as performance
optimizations.
aput — accepting _DeltaSnapshot in channel_values
Snapshot blobs reach aput as _DeltaSnapshot(value) instances inside
checkpoint["channel_values"]. Round-tripping them through
JsonPlusSerializer is sufficient (msgpack ext code EXT_DELTA_SNAPSHOT).
No special-casing is required for correctness.
Optimization (Postgres): hoist non-primitive values into a side blob
table (checkpoint_blobs) and replace the JSON value with True.
References:
InMemorySaver.put— simplest, atlibs/checkpoint/langgraph/checkpoint/memory/__init__.pyPostgresSaver.put— sentinel + side-blob, atlibs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.pySqliteSaver.put— inline, atlibs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py
aput_writes — appending pending writes
One row per (channel, idx) keyed by
(thread_id, checkpoint_ns, checkpoint_id, task_id, idx). No
DeltaChannel-specific logic needed — these rows are what the parent-chain
walk reads.
aget_tuple and alist — joining writes
Must return pending_writes populated (oldest-first per task) so the
default aget_delta_channel_history walk can collect them from each
ancestor.
aget_delta_channel_history — performance optimization
The base implementation works out of the box but does one aget_tuple per
ancestor. For long chains (up to snapshot_frequency = 1000 steps), this
becomes expensive. Override for performance.
Working references (progressively richer):
- InMemory — walks dict keys directly:
libs/checkpoint/langgraph/checkpoint/memory/__init__.py - SQLite — paged DESC scan + per-channel UNION ALL writes fetch:
libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/_delta.pyandlibs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py - Postgres — JSONB-aware two-stage SQL (stage 1: paged
checkpoint_idDESC with parallelchannel_values->key IS NOT NULLchecks; stage 2: per-channel UNION ALL writes + seed blob fetch):libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.pyandlibs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py
adelete_thread — safe cleanup
Wholesale delete of all rows for a thread_id. No DeltaChannel risk
because nothing survives. Copy the pattern from any OSS saver.
Part III — Cleanup Surface
These optional methods (prune, delete_for_runs, copy_thread) have
no OSS implementation yet. The docstrings on BaseCheckpointSaver flag
the danger; this section provides concrete recipes.
The pitfall
- The "latest" checkpoint is rarely a snapshot point (
snapshot_frequencydefaults to 1000). - A naive
keep_latestthat drops intermediate ancestors and theircheckpoint_writessevers the parent chain. - The surviving head's reconstruction walk hits
parent_config = Noneearly, finds no seed, andfrom_checkpoint(MISSING)produces an empty value. Silent data loss — no exception raised. - The same trap applies to
delete_for_runs(a run's writes may be ancestors of a live head) andcopy_thread(head-only copy strands the target).
The get_delta_channel_keepset helper
BaseCheckpointSaver provides a helper that returns the minimum set of
checkpoint_ids that must survive deletion:
keep = await saver.aget_delta_channel_keepset(
config=head_config, channels=["messages", "events"],
)
# `keep` contains the head + every ancestor back to the nearest
# _DeltaSnapshot for each listed channel. Delete everything else.
Pass channels=[] for graphs without DeltaChannel — returns
{head_id} only.
Implementing prune
Three strategies, ordered by recommendation:
Strategy A — Compact-and-prune (requires graph access)
- Resolve current value via
saver.aget_delta_channel_history(config=head, channels=delta_channels). - Fold writes through each channel's
reducerto get the live value. - Rewrite
channel_values[ch] = _DeltaSnapshot(value)on the kept head (update the checkpoint row + blob table). - Now
aget_delta_channel_keepset(channels=[])returns{head_id}— delete everything else.
This is the most space-efficient but requires access to the graph's reducers, so it must be done at the application layer (not purely saver-side).
Strategy B — Walk-to-boundary (purely saver-side, recommended)
async def aprune(self, thread_ids, *, strategy="keep_latest"):
for tid in thread_ids:
heads = await self._list_heads(tid) # your backend code
keep: set[str] = set()
for h in heads:
keep |= await self.aget_delta_channel_keepset(
config=h, channels=DELTA_CHANNELS,
)
all_ids = await self._list_all_ids(tid) # your backend code
to_delete = all_ids - keep
await self._delete_checkpoint_rows(tid, to_delete)
await self._delete_writes(tid, to_delete)
No rewrite needed. Preserves more rows than Strategy A but is correct and simple.
Postgres SQL sketch (alternative to the Python helper — pure SQL recursive CTE):
WITH RECURSIVE ancestors AS (
SELECT checkpoint_id, parent_checkpoint_id,
(checkpoint -> 'channel_values' -> 'messages') IS NOT NULL AS has_snap
FROM checkpoints
WHERE thread_id = $1 AND checkpoint_ns = $2 AND checkpoint_id = $3
UNION ALL
SELECT c.checkpoint_id, c.parent_checkpoint_id,
(c.checkpoint -> 'channel_values' -> 'messages') IS NOT NULL
FROM checkpoints c
JOIN ancestors a ON c.checkpoint_id = a.parent_checkpoint_id
WHERE NOT a.has_snap
)
SELECT checkpoint_id FROM ancestors;
-- This returns the keep-set for channel 'messages'.
Strategy C — Refuse
Detect DeltaChannel usage (e.g. check
delta_updates_since_snapshot in metadata) and skip pruning those threads.
Cheapest correct option for projects that don't need pruning yet.
Implementing copy_thread
Default safe approach: copy all rows for
(source_thread_id, *) to (target_thread_id, *) across all three
tables. Single transaction, simple INSERT ... SELECT. No
DeltaChannel-specific logic needed — the full chain is preserved.
-- Postgres example
INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, ...)
SELECT $target, checkpoint_ns, checkpoint_id, ...
FROM checkpoints WHERE thread_id = $source;
INSERT INTO checkpoint_blobs (thread_id, checkpoint_ns, channel, version, ...)
SELECT $target, checkpoint_ns, channel, version, ...
FROM checkpoint_blobs WHERE thread_id = $source;
INSERT INTO checkpoint_writes (thread_id, checkpoint_ns, checkpoint_id, ...)
SELECT $target, checkpoint_ns, checkpoint_id, ...
FROM checkpoint_writes WHERE thread_id = $source;
Optimization: if you must copy only a sub-range, apply Strategy A (compact a snapshot onto the chosen head first) then copy that single checkpoint.
Implementing delete_for_runs
run_id lives in metadata JSON, not as a column. Implementations need a
JSON predicate:
-- Postgres
SELECT checkpoint_id FROM checkpoints
WHERE thread_id = $1 AND metadata->>'run_id' = ANY($2);
Risk: deleting a run's checkpoints/writes can strand surviving heads in the same thread whose reconstruction walk would have traversed those rows.
Recipe:
- Identify all
(thread_id, checkpoint_id)belonging to the run. - For every still-live head in those threads, compute the keep-set:
keep |= await saver.aget_delta_channel_keepset(config=head, channels=...) - Only delete run rows that fall outside the union of all keep-sets.
- If a row is both "belongs to run" and "in keep-set," it must survive.
Part IV — Validation
Conformance suite
Run the existing conformance tests for any methods you implement:
from langgraph.checkpoint.conformance import validate
report = await validate(my_checkpointer, capabilities={
"delta_channel_history",
"delta_channel_keepset",
"delta_channel_reconstruction",
})
report.print_report()
assert report.passed_all_base()
The three delta capabilities test:
delta_channel_history— walk contract (writes oldest→newest, seed is nearest populatedchannel_values[ch], target's writes excluded).delta_channel_keepset— keep-set contract (empty channels →{target}, snapshot N-back → full chain, multi-channel → union).delta_channel_reconstruction— end-to-end round-trip: write_DeltaSnapshot+ writes viaaput/aput_writes, read viaaget_delta_channel_history, reconstruct viaDeltaChannel.from_checkpoint(seed) + replay_writes(writes), assert value equality.
Recommended additional test
Build a graph with DeltaChannel(snapshot_frequency=10), drive it for
~25 supersteps, run your prune / delete_for_runs / copy_thread, then
assert reconstructed value equals the pre-cleanup value. This is the single
test that catches the silent-corruption mode (conformance alone is
necessary but not sufficient for cleanup methods).
See existing patterns in:
libs/langgraph/tests/test_delta_channel_migration.pylibs/langgraph/tests/test_delta_channel_exit_mode.py
Part V — Future Work
-
DeltaChannelAwarePruneMixin— wrapsaget_delta_channel_keepsettogether with abstract backend hooks into ready-to-useaprune/acopy_thread/adelete_for_runsbodies. Deferred because the hook shape is opinionated and the keepset helper already factors out the dangerous logic.Sketch:
class DeltaChannelAwarePruneMixin: DELTA_CHANNELS: ClassVar[Sequence[str]] = () async def _alist_thread_heads(self, tid: str) -> list[RunnableConfig]: ... async def _alist_thread_checkpoint_ids(self, tid: str) -> set[str]: ... async def _adelete_checkpoint_rows(self, tid: str, ids: set[str]) -> None: ... async def aprune(self, thread_ids, *, strategy="keep_latest"): for tid in thread_ids: heads = await self._alist_thread_heads(tid) keep: set[str] = set() for h in heads: keep |= await self.aget_delta_channel_keepset( config=h, channels=self.DELTA_CHANNELS, ) all_ids = await self._alist_thread_checkpoint_ids(tid) await self._adelete_checkpoint_rows(tid, all_ids - keep) -
OSS
prune/delete_for_runs/copy_threadimplementations on Postgres / SQLite savers. Planned as separate PRs. -
Pregel-side Strategy A compaction helper — needs graph + reducers; lives in
langgraphpackage, not the saver. -
API stabilization — all methods marked Beta here will move to stable once the design proves out in production.
Cross-references
- Docstring warnings on
BaseCheckpointSaver:prune,aprune,delete_for_runs,adelete_for_runs,copy_thread,acopy_thread(libs/checkpoint/langgraph/checkpoint/base/__init__.py). DeltaChannelBeta warning (libs/langgraph/langgraph/channels/delta.py).- Conformance suite
(
libs/checkpoint-conformance/langgraph/checkpoint/conformance/).