Durable State and Checkpoints
How LangGraph snapshots a graph’s state after every step, so a run can survive a crash, get inspected mid-flight, or resume exactly where it left off.
· 10 min read
The problem #
The graph from the last chapter runs and returns a result, and that’s the end of it. Its state, the message history, any intermediate values, lives only inside the Python process that called .invoke(). Kill that process mid-run, and everything it knew is gone. There’s no way to pause a long agent loop and pick it back up an hour later, and no way to ask “what did the agent decide two steps ago” once the call has finished, because that intermediate state was never kept anywhere.
That’s fine for a script that runs once and exits. It stops being fine the moment an agent needs to survive a restart, wait on a slow external step, or be inspected while it’s still running.
The concept #
A checkpointer is what gives a graph memory across calls. You attach one at compile time, and from then on the graph’s state gets snapshotted and saved after every superstep: a single “tick” of the graph in which every node scheduled for that tick runs, including any that ran in parallel, and the checkpoint is only written once all of them have finished. That last part matters more than it looks: a superstep is the unit of durability, not a node. If three nodes run in parallel inside one step, LangGraph waits for all three before committing anything.
What a checkpoint captures #
Each checkpoint is really a small bundle, what the LangGraph source calls a checkpoint tuple, made of four things:
- The state itself, as
channel_values: the current value of every field in your state schema. This is a full snapshot, not a diff against the previous checkpoint. If your state has amessageslist with forty entries, the checkpoint after step forty-one stores all forty-one, not the one that was added. - Metadata: where this step’s writes came from (
"input"for the very first step,"loop"for a normal superstep,"update"if you patched state by hand), the writes each node produced, and a step counter that only ever increases for a given thread. - A
created_attimestamp and aparent_config, the config of the checkpoint immediately before it. Followparent_configbackwards from the latest checkpoint and you get the thread’s entire history as a linked chain, one link per superstep. - Pending writes: as each node inside a superstep finishes, its output is recorded before the superstep as a whole is considered done. If one node in a parallel step fails after a sibling node already succeeded, resuming doesn’t re-run the sibling. Its write is already on record.
thread_id and the checkpoint namespace
#
The full address of a single checkpoint is a triple: thread_id, checkpoint_ns, checkpoint_id. You pass it inside config={"configurable": {"thread_id": "..."}} on every .invoke() or .stream() call.
thread_id is the one you set yourself, a user id, a session id, an order id, anything stable that names “this one conversation.” checkpoint_ns defaults to an empty string for the graph you compiled directly, and only becomes non-empty for checkpoints belonging to a subgraph nested inside it. checkpoint_id names one specific checkpoint within that thread and namespace. Day to day you never set checkpoint_id yourself: leave it out, and LangGraph resolves it to the latest checkpoint for that thread_id, which is what makes resuming a conversation feel automatic. Same thread_id, and the graph loads that latest checkpoint before doing anything else. A new thread_id, and there’s nothing to load, so it starts from a blank state, even on the exact same compiled graph.
The checkpoints for one thread, laid out over two calls, look like this:
sequenceDiagram
participant Caller
participant Saver as Checkpointer (thread_id: conversation-1)
Caller->>Saver: invoke #1 (no prior checkpoint for this thread)
Note over Saver: superstep runs
Saver-->>Saver: write Checkpoint A (parent_config: none)
Caller->>Saver: invoke #2, same thread_id
Saver-->>Caller: load Checkpoint A
Note over Saver: superstep runs, resuming from A
Saver-->>Saver: write Checkpoint B (parent_config: A)
Two calls, two checkpoints, one thread. The second call never re-derives anything from scratch, it picks up the chain exactly where the first one left it.
Reading the history back: get_state and get_state_history
#
That chain isn’t just an internal bookkeeping detail, it’s readable. graph.get_state(config) returns a StateSnapshot for the latest checkpoint matching whatever config you pass: values (the state at that point), next (which node or nodes would run if you invoked again, an empty tuple if the thread ran to completion), config and parent_config (this checkpoint’s own address and its predecessor’s), metadata, created_at, and tasks (what was scheduled to run at that step, handy for seeing exactly what a paused thread was about to do).
graph.get_state_history(config) walks that parent_config chain all the way back and yields every StateSnapshot for the thread, newest first: the complete timeline of every superstep since it began. This is what “time travel” means in practice. Pull a checkpoint_id out of that history, put it back into config, and you can inspect the state exactly as it was at that step, or invoke from it, or hand it to update_state to branch a new checkpoint off that historical point without touching what actually happened afterward.
InMemorySaver versus a persistent backend
#
The three savers differ in more than where the bytes live, they differ in what they actually guarantee. InMemorySaver keeps checkpoints in a Python dict in RAM: enough for local development and quick experiments, but there’s no recovery story at all, the moment the process exits, the dict and every checkpoint in it are gone. SqliteSaver writes checkpoints to a file on disk, so a restart on the same machine picks up where it left off, but it’s still one file, so it doesn’t help once more than one process needs to read or write the same threads. PostgresSaver (and its async counterpart) writes to a real database that many processes can share concurrently, which is what production setups actually reach for.
Orthogonal to which saver you pick is when it writes relative to the superstep, controlled by a durability argument on .invoke() or .stream(): "exit" persists only once the graph run finishes, erroring, or interrupting (fastest, but a crash mid-run loses everything since the last real checkpoint); "async" persists in the background while the next step is already executing (the usual balance); "sync" blocks until the write is confirmed before the next step starts (slowest, safest). That setting only matters if the underlying saver is persistent in the first place. It doesn’t make InMemorySaver durable, since none of it survives the process exiting either way.
What durability actually buys you #
Put the pieces together and a checkpointer earns its keep in three concrete ways. Crash recovery: the process dies mid-run, you restart it, invoke the same thread_id against a persistent saver, and the graph resumes from the last committed checkpoint instead of from nothing. Time travel: get_state_history plus a chosen checkpoint_id lets you inspect the state at any past step, not just the final one, useful for debugging what the agent actually knew when it made a given decision. Forking: pass a historical checkpoint_id back into config and call update_state on it, and LangGraph writes a new checkpoint whose parent_config points at that old one rather than the latest one, without touching the original checkpoint at all. The thread’s history now branches in two: the path that actually happened, and the alternate one you just created from an earlier point in it.
Minimal example #
Using the same two-node graph (agent, tools) from chapter 04, compiled with a checkpointer and invoked twice under the same thread_id:
1from langchain_core.messages import HumanMessage
2from langgraph.checkpoint.memory import InMemorySaver
3
4# `builder` is the same StateGraph (agent -> tools, conditional edge) from chapter 04
5checkpointer = InMemorySaver()
6graph = builder.compile(checkpointer=checkpointer)
7
8config = {"configurable": {"thread_id": "conversation-1"}}
9
10graph.invoke(
11 {"messages": [HumanMessage(content="What's the weather in Tokyo now?")]},
12 config,
13)
14
15# Same thread_id: the graph loads the last checkpoint for "conversation-1"
16# and continues from it, so this call already has the first exchange in context.
17result = graph.invoke(
18 {"messages": [HumanMessage(content="How does that compare to yesterday?")]},
19 config,
20)
21
22print(result["messages"][-1].content)Nothing about the graph itself changed from chapter 04. The only additions are the checkpointer at compile time and the config dict at invoke time. Trace what actually happens underneath, and it maps directly onto the mechanics above: the first .invoke() finds no checkpoint for "conversation-1", so it runs from a blank state and, once its superstep chain finishes, writes what’s effectively Checkpoint A, channel_values holding the full messages list from that exchange, parent_config empty. The second .invoke() sees the same thread_id, resolves it to that latest checkpoint, and loads it before agent ever runs, which is why the state the node reasons over already contains the Tokyo exchange. It never repeats the first question, yet the model answers “compare to what” correctly. That call finishes by writing Checkpoint B, its parent_config pointing back at A.
Nothing here required calling get_state or get_state_history explicitly, LangGraph resolves the latest checkpoint for you on every call, but they’re available on the same graph object if you want to see the mechanism directly instead of just its effect: graph.get_state(config) returns the StateSnapshot for Checkpoint B, and list(graph.get_state_history(config)) returns both snapshots, newest first, with B’s parent_config matching A’s own config.
Trade-offs #
Saving the entire state on every superstep, rather than a diff, is a deliberate trade. It’s also a real cost: an unbounded messages list that keeps growing gets serialized and rewritten to storage in full at every single step, so a long-running thread’s checkpoint size, and the latency of saving it, grows with the conversation rather than with what changed in that step. The alternative, storing only what each node wrote and reconstructing state on read, would make every get_state call an O(n) replay from the start of the thread instead of an O(1) lookup, and would make forking from an arbitrary historical checkpoint far more expensive to compute. LangGraph pays the write-time cost so that reads, time travel, and forking stay cheap and simple.
thread_id scoping is also a choice, not the only possible one. A single shared mutable state for the whole app doesn’t compose: one conversation’s writes stomp another’s. Scoping checkpoints to the compiled graph object itself doesn’t work either, since one compiled graph is meant to serve an unbounded number of independent conversations concurrently, and the graph’s code shouldn’t need to know how many. thread_id decouples “which conversation” from “which graph,” using nothing more specific than a string the application already has a reason to generate, a user id, a session id, an order id. That genericness is the point: it composes with whatever identifier scheme the rest of the system already uses, instead of inventing a new one.
None of this is free, and not every graph needs it. A checkpointer, even InMemorySaver, is overhead you don’t need for a stateless, single-turn call that succeeds or fails atomically and has no reason to pause or resume: no interrupt(), no wait on a slow external step, no requirement to survive a crash mid-run. The moment a thread needs to outlive more than one call, the persistence question is worth answering, and the moment more than one process needs to share those threads, that’s the point where Postgres earns its place over Sqlite.
Summary #
A checkpointer is what turns the explicit graph from chapter 04, nodes, edges, and a typed state, into something that outlives a single .invoke(). Pick a saver at compile time, pass a stable thread_id at invoke time, and every superstep gets committed as a checkpoint: a full state snapshot, chained to the one before it through parent_config, addressable and replayable through get_state and get_state_history. What that buys you depends on the saver, InMemorySaver for convenience with no survival guarantee, SqliteSaver or PostgresSaver when a thread actually needs to survive a restart or be shared across processes, but the capability it unlocks is the same either way: a run that can crash and recover, be inspected mid-history, or fork into an alternate path from any point in its past. That capability is also the prerequisite for the next chapter. interrupt() pauses a graph mid-run and waits for a human, and the only reason there’s anything to resume from is that a checkpointer already snapshotted the state before it stopped.