An agent call ends and the state goes away. Ask a follow-up question and the agent has no idea what you are talking about. That is the default, and it is correct. A graph run is a function call, and a function call does not remember the last one.
A checkpointer changes that. It saves a snapshot of the graph state at each step, keyed by a thread. Add one and the agent continues a conversation. You can also inspect any past step. You can replay from it. You can recover from a crash without a repeat of the work that finished.
This is the first of three parts. Part 1 covers the mechanism: what a checkpoint holds, when the graph writes one, and how to read them back. Part 2 makes the storage durable with SQLite. Part 3 covers replay, forking, interrupts, and fault tolerance.
Every example runs locally with Ollama. It needs no API keys and no per-token billing. The versions used throughout are langgraph 1.2.11, langgraph-checkpoint 4.2.0, langchain 1.3.16, and langchain-ollama 1.1.0. The model is qwen3:8b.
The Problem
Here is an agent with two tools and no checkpointer. It looks up network devices and change windows, and it is the same agent the multi-agent series used:
from langchain.agents import create_agent
agent = create_agent(
model=make_model(),
tools=[lookup_device, change_window],
system_prompt=(
"You answer network operations questions. "
"Use the tools for every fact. "
"Never state a fact you did not get from a tool."
),
)
first = agent.invoke(
{"messages": [{"role": "user", "content": "What site is dfw-core-01 in?"}]}
)
second = agent.invoke(
{"messages": [{"role": "user", "content": "What is the change window for it?"}]}
)
The first call works. The second call has nothing to work with:
--- call 1 ---
[tool] lookup_device('dfw-core-01')
answer: The device **dfw-core-01** is located at the site **TX-ALPHA-3**.
--- call 2 ---
answer: Could you please clarify which site code you are referring to?
The word “it” had a referent one call ago. The second invoke started from an empty state, so the agent asked a reasonable question about a conversation it never saw.
Add a Checkpointer
Two changes fix it. Pass a checkpointer to create_agent, and pass a thread_id on every call:
from langgraph.checkpoint.memory import InMemorySaver
checkpointer = InMemorySaver()
agent = create_agent(
model=make_model(),
tools=[lookup_device, change_window],
system_prompt="...",
checkpointer=checkpointer,
)
config = {"configurable": {"thread_id": "netops-1"}}
first = agent.invoke({"messages": [{"role": "user", "content": "What site is dfw-core-01 in?"}]}, config)
second = agent.invoke({"messages": [{"role": "user", "content": "What is the change window for it?"}]}, config)
Same questions, same model, same tools:
--- call 1 ---
[tool] lookup_device('dfw-core-01')
answer: The device **dfw-core-01** is located at the site **TX-ALPHA-3**.
--- call 2 ---
[tool] change_window('TX-ALPHA-3')
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**.
messages on the thread: 8
checkpoints on the thread: 10
The second call resolved “it” to TX-ALPHA-3 and called the right tool with the right argument. The thread holds eight messages across both calls.
The thread_id is the important part. It is the primary key the checkpointer stores under. Two calls with the same thread_id share a conversation, and two calls with different ones do not. Leave it out and the call fails before any node runs:
ValueError: Checkpointer requires one or more of the following 'configurable'
keys: thread_id, checkpoint_ns, checkpoint_id
Note the last line. Two calls produced ten checkpoints, not two. The next section explains why.
When the Graph Writes a Checkpoint
The agent above is a graph with several nodes, so its checkpoint count is hard to read. Use a small graph instead. This one has two nodes, one channel with a reducer, and nothing else:
from operator import add
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
foo: str
bar: Annotated[list[str], add]
def node_a(state: State):
return {"foo": "a", "bar": ["a"]}
def node_b(state: State):
return {"foo": "b", "bar": ["b"]}
builder = StateGraph(State)
builder.add_node(node_a)
builder.add_node(node_b)
builder.add_edge(START, "node_a")
builder.add_edge("node_a", "node_b")
builder.add_edge("node_b", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "1"}}
graph.invoke({"foo": "", "bar": []}, config)
Two nodes ran. The graph wrote four checkpoints:
[node] node_a runs
[node] node_b runs
final state: {'foo': 'b', 'bar': ['a', 'b']}
checkpoints written: 4
[0] step=-1 source=input next=('__start__',) values={'bar': []}
[1] step=0 source=loop next=('node_a',) values={'foo': '', 'bar': []}
[2] step=1 source=loop next=('node_b',) values={'foo': 'a', 'bar': ['a']}
[3] step=2 source=loop next=() values={'foo': 'b', 'bar': ['a', 'b']}
LangGraph writes one checkpoint per super-step. A super-step is one tick of the graph, in which every node scheduled for that tick runs. Nodes in the same tick can run in parallel, and they share one checkpoint.
Read the four rows from the top.
Step -1 is the input. The graph recorded that you called it before any node ran. Its values hold only bar. The foo channel has no reducer, and nothing wrote to it yet.
Step 0 holds the full input and names node_a as the next node. Step 1 holds what node_a produced and names node_b. Step 2 holds the final state, and its empty next means the graph finished.
A rule falls out of this. A graph with N nodes in a line writes N plus two checkpoints. The same graph, run at four widths, confirms it:
1 nodes in a line -> 3 checkpoints
2 nodes in a line -> 4 checkpoints
3 nodes in a line -> 5 checkpoints
5 nodes in a line -> 7 checkpoints
You can resume from any of them, and only from them. Super-step boundaries are the resume points, which matters in Part 3.
The bar channel shows the reducer working. node_a wrote ["a"] and node_b wrote ["b"], and the checkpoint at step 2 holds ['a', 'b']. The foo channel has no reducer, so each write replaced the last one.
What a Snapshot Holds
get_state returns a StateSnapshot. Here is a real one, printed field by field:
--- latest snapshot
values {'foo': 'b', 'bar': ['a', 'b']}
next ()
config {'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f1a24af-9f91-6da0-8002-8d1fd3dcce4a'}}
metadata {'source': 'loop', 'step': 2, 'parents': {}}
created_at 2026-08-27T19:10:33.032843+00:00
parent_config {'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f1a24af-9f91-615c-8001-edb6f46f805a'}}
tasks ()
Those are the real attribute names. The script reads each one off the object with getattr, so what you see is what StateSnapshot exposes.
Each field earns its place.
values holds the state channels at this point. This is the part you usually want.
next names the nodes that run next. An empty tuple means the graph finished. A non-empty one means the run stopped before those nodes, which happens on an interrupt or an error.
config carries the three keys that identify a checkpoint: thread_id, checkpoint_ns, and checkpoint_id. The checkpoint_id is a UUID, and Part 3 uses it to travel back.
metadata carries source, step, and parents. The source value is input for the call itself, loop for a normal step, and update for a checkpoint that update_state created. The step value counts super-steps from -1.
One correction is worth making here. The current documentation lists a writes key in metadata, holding the node outputs. On langgraph 1.2.11 that key is absent, and the metadata holds only the three keys above. The node output moved to the result field on each task instead. Read your own metadata rather than the example in the docs.
created_at is an ISO 8601 timestamp.
parent_config points at the checkpoint before this one. It is None for the first checkpoint, and it makes the history a linked list.
tasks holds the work scheduled at this step. On a finished checkpoint it is empty. On an unfinished one it names each task, along with its error and its interrupts:
(PregelTask(id='b02b8082-5333-d7aa-8ae2-3fdd1799f094', name='node_b',
path=('__pregel_pull', 'node_b'), error=None, interrupts=(), state=None,
result={'foo': 'b', 'bar': ['b']}),)
That result field is where the node output lives on this version.
Reading the History
get_state gives you the latest checkpoint. get_state_history gives you all of them, newest first:
latest = graph.get_state(config)
history = list(graph.get_state_history(config))
The parent_config chain links them into one line:
=== the parent chain, newest first ===
1f1a247d-9543-6a1a-8002-aed1be775f0c parent=1f1a247d-9542-6c78-8001-492a59135387
1f1a247d-9542-6c78-8001-492a59135387 parent=1f1a247d-9541-6a9e-8000-9a0e581227d9
1f1a247d-9541-6a9e-8000-9a0e581227d9 parent=1f1a247d-953f-6910-bfff-1c4ffec39f02
1f1a247d-953f-6910-bfff-1c4ffec39f02 parent=None
Each row’s parent is the row below it, and the oldest has no parent. Part 3 breaks this straight line into a tree.
Add a checkpoint_id to the config to read one specific checkpoint:
config = {"configurable": {"thread_id": "1", "checkpoint_id": older_id}}
one = graph.get_state(config)
checkpoint_id=1f1a247d-9541-6a9e-8000-9a0e581227d9
values={'foo': '', 'bar': []} next=('node_a',)
That is the state of the world before node_a ran, recovered after the run finished.
Finding One Checkpoint
History is a plain list, so ordinary Python finds what you want. Three filters cover most needs:
# The checkpoint just before a node ran.
before_b = next(s for s in history if s.next == ("node_b",))
# A checkpoint by step number.
step_1 = next(s for s in history if s.metadata["step"] == 1)
# Every checkpoint of a given kind.
inputs = [s for s in history if s.metadata["source"] == "input"]
before node_b ran: values={'foo': 'a', 'bar': ['a']}
step 1: values={'foo': 'a', 'bar': ['a']}
input checkpoints: 1
get_state_history also takes limit and before, which matter once a thread has hundreds of checkpoints:
limit=2 returns 2 snapshots
before=newest returns 3 snapshots
before takes a config rather than a timestamp. Pass the config of a checkpoint and you get everything older than it.
Subgraphs Get Their Own Namespace
Every checkpoint carries a checkpoint_ns. For the parent graph it is an empty string. For a subgraph it names the node that invoked it, followed by a UUID. Nested subgraphs join their namespaces with a | character.
A two-level graph shows both:
[outer_node] checkpoint_ns='outer_node:9154c6e3-9a64-20f3-7058-73e727555c0f'
[inner_node] checkpoint_ns='child:dc030440-f582-fcce-fff2-829ae825493f|inner_node:463a42cb-a210-6e1a-af51-a38e6faf8e03'
result: {'value': 'start-outer-inner'}
=== parent graph checkpoints ===
step=2 ns=''
step=1 ns=''
step=0 ns=''
step=-1 ns=''
Two things are worth separating here, because the docs blur them.
The stored parent checkpoints all use an empty namespace, exactly as documented. The namespace visible inside a node, from config["configurable"]["checkpoint_ns"], is task-scoped and is not empty even in the parent graph. The documentation comment says that value is "" for the parent graph, and on this version it is not.
The practical takeaway stands either way. get_state_history on the parent returns parent checkpoints only. To reach subgraph state, pass subgraphs=True to get_state and read the state field on each task.
What InMemorySaver Cannot Do
InMemorySaver holds every checkpoint in a Python dictionary. That makes it perfect for a tutorial and useless for anything that restarts.
Three limits follow. The process exit deletes every thread. A second process cannot see the first one’s threads. Memory grows with the conversation, and nothing evicts it.
Nothing above depends on InMemorySaver. Every method in this post belongs to the checkpointer interface, so a SQLite or Postgres saver answers the same calls the same way. Only the storage changes.
What Comes Next
You can now save state to a thread. You can read any past checkpoint and walk the history. You also know why a two-node graph writes four checkpoints.
Part 2 makes it durable. It installs langgraph-checkpoint-sqlite and opens the database file. It shows the two tables and the real rows behind everything above. It also covers serializers, encryption, the three durability modes, and how much disk a conversation costs.