Checkpointing is graph runtime persistence. It is separate from harness memory and long-term stores.
LangGraph stores graph state through checkpointers. The checkpoint package defines this as the graph persistence layer: it saves graph state at every superstep and enables human-in-the-loop, memory between interactions, durable execution, and replay. Its core persistence unit is a checkpoint tuple: the checkpoint itself plus config, metadata, parent config, and pending writes.
Checkpoint coordinates are thread-based:
thread_ididentifies the checkpoint lineage for a conversation, workflow, or tenant-isolated run series.checkpoint_idoptionally selects a specific point in that thread, including time-travel or replay from the middle of a thread.checkpoint_nsscopes nested graph/subgraph state so subgraphs can share a checkpointer without colliding with parent checkpoints.
#[async_trait]
pub trait Checkpointer: Send + Sync {
async fn get_tuple(
&self,
query: CheckpointQuery,
) -> Result<Option<CheckpointTuple>>;
async fn list(
&self,
query: CheckpointListQuery,
) -> Result<Vec<CheckpointTuple>>;
async fn put(
&self,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> Result<CheckpointConfig>;
async fn put_writes(
&self,
config: CheckpointConfig,
writes: Vec<PendingWrite>,
task_id: TaskId,
task_path: TaskPath,
) -> Result<()>;
}Checkpoint tuple:
pub struct CheckpointTuple {
pub config: CheckpointConfig,
pub checkpoint: Checkpoint,
pub metadata: CheckpointMetadata,
pub parent_config: Option<CheckpointConfig>,
pub pending_writes: Vec<PendingWrite>,
}Checkpoint fields:
- version
- checkpoint id
- thread id
- checkpoint namespace
- graph id
- run id
- timestamp
- channel values
- channel versions
- versions seen by each node
- updated channels
- next active nodes
- pending sends
- pending writes
- task outcomes
- interrupts
- parent checkpoint config
- metadata source:
input,loop,update, orfork
Durability modes:
sync: persist before the next step starts.async: persist while the next step executes.exit: persist only when the graph exits.
Backends:
- in-memory
- file-backed JSON/JSONL
- SQLite
- Postgres later
Thread operations:
- list checkpoints for a thread
- delete all checkpoints for a thread
- delete checkpoints by run id
- copy a thread to a new thread id
- prune checkpoints with a documented strategy
Delta channels require careful copy and prune semantics. A checkpoint backend must not keep only the latest checkpoint if the latest checkpoint depends on ancestor pending writes or a previous delta snapshot.
LangGraph also has a BaseStore, but that is not the same thing as graph state
checkpointing. Stores provide long-term memory that can persist across threads
and conversations. They support hierarchical namespaces, key-value items,
metadata, and optional vector search.
TinyAgents should mirror this separation:
- checkpointers store execution state needed to resume a graph exactly
- stores hold application memory, records, artifacts, and searchable data that graph or harness nodes may read and write
Compiled graphs may receive a store registry at compile/run time, and
GraphContext may expose stores to nodes, but the executor must not use stores
as a substitute for checkpoints.
Compiled graphs with checkpointing should expose:
get_state(thread_id, checkpoint_id)get_state_history(thread_id, before, limit, filter)update_state(thread_id, values, as_node, task_id)bulk_update_state(thread_id, supersteps)fork_state(source_checkpoint, target_thread_id)
State snapshots contain:
- current values
- next node names
- config used to fetch the snapshot
- checkpoint metadata
- creation timestamp
- parent config
- tasks for the next step
- task errors and results from attempted work
- pending interrupts
Manual state updates are graph writes. They must pass through the same channel
reducers, produce checkpoint metadata with source update, and validate
as_node when a caller attributes the write to a node.
Time travel is implemented by invoking or streaming from an older checkpoint config or by forking a thread. It must not mutate old checkpoint records.
The checkpoint core lives in src/graph/checkpoint/:
Checkpoint<State>— the persisted superstep snapshot (thread/checkpoint ids, parent lineage, namespace, committed state, next/completed nodes, pending writes, interrupts, and free-form metadata).CheckpointMetadata— the lightweight listing record. Itssourcefield is a typedCheckpointSource(no longer a bare string).CheckpointSource—Input | Loop | Update | Forkwith serde (lowercase wire form) andDisplay.CheckpointSource::parserecovers it from a string.DurabilityMode—Sync | Async | Exit, defaultSync.Syncpersists a checkpoint before the next step starts.Asynchands non-terminal boundary writes to spawned background tasks so checkpoint I/O stays off the superstep critical path; the checkpoint id is minted up front so lineage stays chained. A failed background write is never silently lost: the run fails at the next durability boundary that observes it, and every in-flight write is awaited (drained) at the terminal, interrupt, and failure boundaries so the run result reflects persistence failures. The terminal and interrupt checkpoints themselves are always written synchronously;CheckpointSavedevents for background writes arrive when the write completes, so their ordering relative to later step events is not deterministic. Outside a tokio runtimeAsyncdegrades toSync.Exitpersists only the terminal checkpoint and any interrupt boundary (interrupts must persist so the run can resume). Set it withCompiledGraph::with_durability(mode).CheckpointConfig { thread_id, checkpoint_id, namespace }— checkpoint coordinates.CheckpointConfig::latest(thread_id)addresses the newest checkpoint at the root namespace.CheckpointTuple<State> { config, checkpoint, parent_config, pending_writes }— the documented core persistence unit.Checkpointer::get_tuple(config)— a default trait method composed fromgetso every backend gets it for free; it resolves the concrete config and the parent's config from the loaded record.
The Checkpointer trait retains its existing put / get / list surface.
Two backends are bundled:
InMemoryCheckpointer— anArc<Mutex<..>>map, cheap to clone (clones share storage), for tests and ephemeral runs.FileCheckpointer— a durable JSON/JSONL backend that survives process restarts. Each thread maps to one append-only<thread>.jsonlfile under a base directory (one serializedCheckpointper line, in insertion order).putappends a line;get/liststream the thread file;delete_*/prunerewrite it (and remove it once empty);copy_threadcopies the file with thethread_idrewritten on every record. Thread ids are percent-escaped into a single safe filename component, andlist_threadsrecovers each canonical thread id from the first record rather than un-escaping the filename. TheCheckpointerimpl is bound byState: Serialize + DeserializeOwned(the trait itself stays bound-free, so non-serializable states still use the in-memory path).Checkpoint<State>derives serde's conditional (de)serialization for this.SqliteCheckpointer— a durable, queryable backend behind the optionalsqlitecargo feature (rusqlitewith thebundledSQLite). Open a file withSqliteCheckpointer::open(path)or an ephemeral database withSqliteCheckpointer::in_memory(); clones share oneArc<Mutex<Connection>>, so in-memory clones share data. Each checkpoint is one row in acheckpointstable keyed by(thread_id, checkpoint_id): the full record is stored as JSON in arecordcolumn, while the parent id, namespace (json), next nodes (json), source, step, run id, and an interrupts flag are projected into their own columns so thread listing and parent-chain walks are served by indexes (idx_checkpoints_thread,idx_checkpoints_lookup) without deserializing whole states. A monotonicseqprimary key preserves insertion order, soget(None)returns the most recent row,get(Some(id))the latest row with that id, andlistwalks rows in insertion order — matching the other backends. LikeFileCheckpointer, the impl is bound byState: Serialize + DeserializeOwned. Postgres backends remain future work.
Checkpoint and CheckpointMetadata now carry an optional run_id
(back-compatible — pre-existing/manual records leave it None). The executor
stamps every boundary checkpoint with the producing run id.
The Checkpointer trait exposes the documented thread operations. Three are
storage-specific primitives (no default body): list_threads, delete_thread,
and the low-level delete_checkpoints(thread_id, ids). The higher-level
operations are default trait methods composed from those plus list/get/put,
so every backend inherits them:
delete_by_run(thread_id, run_id)— deletes the checkpoints stamped with a run id (composed fromlist+delete_checkpoints), returning the count.get_thread(thread_id)— bulk-reads every full checkpoint record for a thread in listing order. The default is composed fromlist+get(one lookup per id); all three bundled backends override it with a single-pass read (map clone / one file parse / one indexed range query).copy_thread(source, target)— deep-copies every checkpoint into a new thread id, preserving each record'scheckpoint_idandparent_checkpoint_idso the lineage spine stays walkable for time-travel/resume (composed fromget_thread+put, so the source thread is read once).prune(thread_id, keep_last)— retains the most recentkeep_lastcheckpoints plus the fullparent_checkpoint_idancestor chain of every retained checkpoint, then deletes the rest. Protecting the entire ancestor chain is what honors the delta-channel warning: a kept checkpoint that stores only a delta (or depends on an ancestor's pending writes/snapshot) can never be orphaned from the state it needs.keep_last == 0is clamped to1so the latest checkpoint always survives.
InMemoryCheckpointer implements the three storage primitives (plus a
single-pass get_thread); it inherits delete_by_run, copy_thread, and
prune from the trait defaults.
CompiledGraph exposes the documented inspection/time-travel surface when a
checkpointer is configured (every method returns TinyAgentsError::Checkpoint
if it is not). A StateSnapshot<State> bundles the committed values, the
next_nodes/tasks that would run on resume, the config addressing the
snapshot, its parent_config, the listing metadata, and any
pending_interrupts.
get_state(thread_id, checkpoint_id)— loads a snapshot (latest whencheckpoint_idisNone);Ok(None)for an unknown thread/checkpoint.get_state_history(thread_id, limit)— snapshots newest-first, walking theparent_checkpoint_idlineage back from the latest checkpoint;limitcaps the count.update_state(thread_id, update, as_node)— a manual graph write. Theupdateis folded through the sameStateReducerthe executor uses, on top of the thread's latest committed state, and persisted as a new checkpoint with sourceupdate.as_nodemust name a real node (MissingNodeotherwise); the write is attributed to it and the new checkpoint's pending nodes become that node's routing successors. Withas_node == Nonethe latest pending set is preserved.bulk_update_state(thread_id, updates)— applies a sequence of(update, as_node)pairs as successiveupdatecheckpoints, each layered on the previous one's committed state; returns the last config (errors on an empty sequence).fork_state(source_thread, source_checkpoint_id, target_thread)— copies a checkpoint into a new thread as a fresh root (no parent) with sourcefork. The source record is read withgetand never mutated, so forks are non-destructive time travel.
Time-travel resume is resume_from(thread_id, target, command) where target
is a ResumeTarget (Latest or Checkpoint(id)). resume is shorthand for
ResumeTarget::Latest. Resuming from an older checkpoint replays its pending
nodes forward (applying command's resume value to any interrupted node)
without rewriting history — new boundary checkpoints are appended to the thread.