17. State Persistence and Checkpointing
In Chapter 15 we rebuilt the agent loop as a StateGraph, and in Chapter 16 we used prebuilt components and multi-branch routing to build something more elaborate. Every graph we've written so far shares one limitation: the graph does not hold on to its state.
The graph holds and manages state for the duration of a single invoke() call, and not a moment longer. When you call it, LangGraph creates a fresh state, runs the nodes, merges each return value into the state according to the reducer rules, and hands the final state back to the caller. Once it has handed that state over, the graph no longer remembers it. The next invoke() starts from a brand-new state with no connection to the previous call.
Two problems follow. First, messages is part of the state too, so the agent can't remember anything you said earlier. Second, if a run fails partway through, everything it accomplished up to that point disappears. Say the third node raises an exception: the results produced by the first two nodes go with it, and you have to start over from the beginning. Back in 15.1 we listed "recovering from interruptions" as one of the reasons to reach for LangGraph — this is the problem we had in mind.
LangGraph handles this at the framework level. Attach a checkpointer to a graph and LangGraph will automatically save a snapshot of the state at each step of execution. Those saved snapshots outlive the invoke() call, so the next call can pick up where the last one left off. This property—state surviving beyond a single run—is called persistence.
You've actually used a checkpointer before. In Chapter 11, when we gave the conversational RAG agent multi-turn memory, we passed create_agent(..., checkpointer=InMemorySaver()) and a thread_id. At the time, all you needed to know was that the checkpointer keeps conversation history per thread_id; we never explained how. And in Chapter 16, when we introduced the checkpointer parameter, we said "we'll cover how this works in Chapter 17." This is that chapter.
It comes in three parts. In 17.1 we attach a checkpointer to a graph and hold multi-turn conversations with thread_id. In 17.2 we open up the saved checkpoints to see what the agent knew at any given moment—checkpoints are your main tool for tracking down why an agent misbehaved. In 17.3 we take a graph that failed mid-run and resume it from where it stopped rather than from the beginning.
17.1) Carrying State Across Calls
17.1.1) A Graph That Forgets
We opened this chapter by saying a graph doesn't hold on to its state. Let's confirm that in code.
The graph below has the same shape as the say_hello graph from 15.2. The only difference is that the node returns an LLM response instead of a fixed string.
from langgraph.graph import StateGraph, MessagesState, START, END
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-5-mini")
def llm_call(state: MessagesState):
response = model.invoke(state["messages"])
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node(llm_call)
builder.add_edge(START, "llm_call")
builder.add_edge("llm_call", END)
graph = builder.compile() # no checkpointerNow let's have a two-turn conversation. We tell the model our name in the first call, then ask it what our name is in the second.
# First call — we give our name
graph.invoke({"messages": [{"role": "user", "content": "Hi, my name is Bob."}]})
# Second call — we ask for it back
result = graph.invoke({"messages": [{"role": "user", "content": "What's my name?"}]})
print(result["messages"][-1].content)Output:
I'm sorry, but I don't have your name. Could you tell me what it is?The only message the model received on the second call was "What's my name?". The state from the first call is no longer in the graph, so the earlier messages—the ones carrying the name—never made it to the model.
We could of course fix this ourselves. Hold on to the messages returned by the first call and pass them along with the second. That's exactly how we managed conversation history back in Chapter 8. But then we'd be writing our own code to store and retrieve history for every conversation and every user. That's the work a checkpointer takes off your hands.
17.1.2) Checkpoints and Checkpointers
A checkpointer is an object whose job is to save state. You create an instance—InMemorySaver(), for example—and pass it to builder.compile(checkpointer=...) to attach it to your graph.
Once a checkpointer is attached, the graph copies the whole state and saves it as execution proceeds. Each of those saved copies is called a checkpoint. Think of it as a photograph: the entire state at that instant, preserved exactly as it was.
An autosave in a video game is the right mental picture. The game quietly records your progress whenever you pass a meaningful point, so you can quit and come back later, or die without having to start over from the beginning. A checkpointer does exactly this for a graph.
So when is a "meaningful point"? LangGraph divides a graph's execution into stages, and each stage is called a super-step. A checkpoint is saved every time a super-step finishes.
The reason it's a super-step and not just a step is that a single stage can run several nodes at once. In a graph like the one from 17.1.1, where the nodes form a straight line, running one node is one super-step. But in a graph where several nodes run in parallel, all of those nodes together make up a single super-step.
So even a single call to invoke() leaves several checkpoints behind. We'll pull them out and look at exactly what each one contains in 17.2.
The InMemorySaver we've been using as our example is the simplest checkpointer there is. As the name suggests, it stores checkpoints in the process's memory (RAM). There's nothing to install and nothing to configure, which makes it a good fit for learning and local development. The trade-off is that every saved checkpoint disappears when the process restarts. We'll look at the production alternatives in 17.1.5.
17.1.3) Adding a Checkpointer
Attaching a checkpointer takes only two things.
- Create a checkpointer instance and pass it to
compile(). - Pass a
configcontaining athread_idwhenever you callinvoke().
We'll get to why the second one is needed shortly. For now, just know that it tells the checkpointer which of its saved conversations you want to continue.
Let's apply both to the graph from 17.1.1.
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="gpt-5-mini")
def llm_call(state: MessagesState):
response = model.invoke(state["messages"])
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node(llm_call)
builder.add_edge(START, "llm_call")
builder.add_edge("llm_call", END)
# 1. Create a checkpointer and pass it to compile()
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
# 2. Pass a config carrying a thread_id to invoke()
config = {"configurable": {"thread_id": "1"}}
graph.invoke(
{"messages": [{"role": "user", "content": "Hi, my name is Bob."}]},
config,
)
result = graph.invoke(
{"messages": [{"role": "user", "content": "What's my name?"}]},
config,
)
print(result["messages"][-1].content)Output:
Your name is Bob.The same two calls as in 17.1.1, and a different outcome. This time the name sticks.
Here's why. A graph with a checkpointer attached loads the saved state before it runs the llm_call node. That state already contains the first exchange. The new message we passed in is then merged into it. As we saw in 15.2.2, the messages field carries the add_messages reducer, so the new message is appended to the existing list. The model ends up receiving three messages: the greeting, its own first reply, and the new question.
We send only the new message, and LangGraph loads the earlier conversation from the last checkpoint. The conversation history we managed by hand in Chapter 8 is now managed by LangGraph.
17.1.4) thread_id: The Identifier That Separates Conversations
A thread_id is an identifier that distinguishes one conversation from another. You choose the value. We used "1" in 17.1.3, but any string will do. Call the graph with the same thread_id and you continue that conversation; call it with a different one and you start a separate conversation.
Let's confirm that. We'll have Alice and Bob hold different conversations through the same graph.
def send(thread_id: str, text: str) -> str:
config = {"configurable": {"thread_id": thread_id}}
result = graph.invoke(
{"messages": [{"role": "user", "content": text}]},
config,
)
return result["messages"][-1].content
# Alice's conversation
send("alice", "My favorite color is teal.")
# Bob's conversation — a different thread_id
send("bob", "My favorite color is orange.")
# Ask each of them again
print("Alice:", send("alice", "What's my favorite color?"))
print("Bob: ", send("bob", "What's my favorite color?"))Output:
Alice: Your favorite color is teal.
Bob: Your favorite color is orange.Both conversations went through the same graph object and the same checkpointer, yet they never mixed. The thread_id is the primary key the checkpointer uses to store and look up state. Different keys, completely separate storage.
So what happens if you leave the value out?
graph.invoke({"messages": [{"role": "user", "content": "Hello"}]})Output:
ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_idThe graph doesn't run at all. Once a checkpointer is attached, thread_id is not optional—it's required.
Given what we just saw, that makes sense. Before running a node, the checkpointer has to load the saved state — and the thread_id is what tells it which conversation's state to load.
This is the basic shape of a chatbot service: one graph, one checkpointer, and one thread_id per user or per chat room.
17.1.5) The Limits of InMemorySaver, and Production Alternatives
We said earlier that InMemorySaver keeps checkpoints in memory. Two limitations come with that choice.
Restart the process and everything is gone. Redeploy the service or bring the server back up, and every conversation accumulated so far disappears.
Separate processes can't share it. A real service spreads incoming requests across several processes. Each process has its own memory, so a conversation saved by process A is invisible to process B. A user can send the same thread_id every time and still watch the conversation fall apart, depending on which process happens to pick up the request.
That's why production uses checkpointers that store checkpoints in a database.
SqliteSaver/AsyncSqliteSaver(langgraph-checkpoint-sqlite) — stores everything in a single file. A good fit for a small service running on one server, or for a local prototype.PostgresSaver/AsyncPostgresSaver(langgraph-checkpoint-postgres) — stores checkpoints in a database server. Add more servers and every process still sees the same checkpoints. This is the checkpointer the LangGraph docs recommend for production.
All of them implement the same interface as InMemorySaver. Your graph code, your nodes, and the way you use thread_id stay exactly as they are. The only thing that changes is how you create the checkpointer.
create_agent, which you met in Chapter 16, uses checkpointers the same way. Pass a checkpointer to itscheckpointerparameter, and pass aconfigcarrying athread_idtoinvoke(). This is what made the conversational RAG agent in Chapter 11 remember earlier turns.
We'll keep using InMemorySaver for the rest of this chapter. Wherever the checkpoints happen to live, the way you work with them is the same.
17.2) Inspecting State and Debugging
In 17.1.2 we said the checkpointer saves state at every super-step. Let's pull those checkpoints out and look at them.
Opening up saved checkpoints isn't idle curiosity. Agents misbehave. They call tools in a loop that never ends, they lose track of earlier turns, they take a branch you never expected. To find out why, you need to know what the state looked like at that moment—and the checkpoints have the answer.
LangGraph gives you two methods.
graph.get_state(config)— returns the most recent checkpoint for that conversation.graph.get_state_history(config)— returns every checkpoint for that conversation, newest first.
Both need a thread_id in the config, since they have to know whose checkpoints you're asking for.
The checkpoints these two methods hand back are represented as StateSnapshot objects.
17.2.1) StateSnapshot: What's Inside a Checkpoint
Let's see what a checkpoint actually contains. We won't use an LLM for this example. An LLM returns something different every time, which is no help when we want to walk through each field one by one. Instead we'll build a small graph that returns fixed values.
The state will have two kinds of fields. foo has no reducer, so it gets overwritten; bar has one, so it accumulates. It's the same arrangement as the add_messages reducer we attached to messages in 15.2.2—here we use Python's operator.add as the reducer to concatenate lists.
from operator import add
from typing_extensions import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
class State(TypedDict):
foo: str # no reducer → overwrite
bar: Annotated[list[str], add] # add reducer → accumulate
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)
snapshot = graph.get_state(config)
print(snapshot)Output:
StateSnapshot(
values={'foo': 'b', 'bar': ['a', 'b']},
next=(),
config={'configurable': {'thread_id': '1', 'checkpoint_ns': '',
'checkpoint_id': '1f17da03-9654-65d8-8002-9e59231bb481'}},
metadata={'source': 'loop', 'step': 2, 'parents': {}},
created_at='2026-07-12T03:17:33.637368+00:00',
parent_config={'configurable': {'thread_id': '1', 'checkpoint_ns': '',
'checkpoint_id': '1f17da03-9653-6ca0-8001-7c24c8ec66f2'}},
tasks=(),
interrupts=()
)Eight fields. Let's take them one at a time.
values— the state as it stood at this checkpoint.baris['a', 'b']because theaddreducer accumulated what both nodes returned;foois'b'because it has no reducer, so the last write won. This is the field that answers "what did the state look like at that moment?"next— a tuple of node names to be run after this checkpoint. An empty()means there's nothing left to run, i.e. the graph finished.('node_b',)meansnode_bis still ahead.config— the address of this checkpoint.thread_ididentifies the conversation, andcheckpoint_ididentifies which moment within it. LangGraph assigns thecheckpoint_idautomatically every time it saves a checkpoint.metadata— bookkeeping about the run.sourcetells you where the checkpoint came from:"input"means it was built from the input you handed toinvoke(), and"loop"means it was produced while the graph was running.stepis the super-step number.created_at— when the checkpoint was saved. Handy when you're lining things up against your logs.parent_config— theconfigof the checkpoint immediately before this one. Follow it and you can walk backward through the run. It'sNonefor the very first checkpoint.tasks— the execution record for the nodes listed innext. At the moment a checkpoint is saved, those nodes have not run yet; once they do, their outcome is attached to this checkpoint. A node that succeeded leaves its return value inresult, and a node that failed leaves its exception inerror.interrupts— where the graph paused to hand control back to a person. LangGraph can stop mid-run and wait for someone to approve a step or supply a value, and this field records those pauses.
You read the fields as attributes. metadata is a dictionary, so you pull values out of it with a key.
snapshot = graph.get_state(config)
print(snapshot.values) # {'foo': 'b', 'bar': ['a', 'b']}
print(snapshot.next) # ()
print(snapshot.metadata["step"]) # 2Of these, the one you'll reach for most often while debugging is next. If next isn't empty, the graph didn't make it to the end—it stopped somewhere in the middle. And when we resume a failed graph in 17.3, this field is where we start.
17.2.2) Walking the Checkpoint History
get_state() shows you only the most recent checkpoint. But debugging often means asking "how did we end up here?"—and for that you need the whole trajectory of the run. get_state_history() gives it to you.
for snap in graph.get_state_history(config):
print(f"step={snap.metadata['step']:>2} "
f"next={str(snap.next):<16} values={snap.values}")Output:
step= 2 next=() values={'foo': 'b', 'bar': ['a', 'b']}
step= 1 next=('node_b',) values={'foo': 'a', 'bar': ['a']}
step= 0 next=('node_a',) values={'foo': '', 'bar': []}
step=-1 next=('__start__',) values={'bar': []}The newest checkpoint comes first, so work from the bottom up to follow the run in order.
- step -1 — right after
invoke()received the input. Notice that the{"foo": "", "bar": []}we passed in doesn't appear invalues. Getting the input into the state is itself a stage, and that stage hasn't run yet. The__start__innextis the internal node that does it.barshows up as[], but that is not the value we passed in. A field with a reducer starts out with an empty value for writes to accumulate into.foohas no reducer, so it has no starting value at all — which is why it doesn't appear here. - step 0 —
__start__has run and the input is now in the state.foo=''andbar=[]are the values we passed in.node_ais up next. - step 1 — the result of running
node_a.foois now'a'andbaris['a'], withnode_bnext. - step 2 — the result of running
node_b.nextis empty, so the graph is done.
A non-empty next at step 0 or step 1 does not mean the graph stopped there. A checkpoint taken while the graph is still running naturally has a node lined up next. When 17.2.1 said "a non-empty next means the graph stopped," it was talking about the checkpoint at the point where something went wrong. In the middle of the history, next simply shows you which path the graph took.
17.3) Resuming from Where It Failed
Because the state is saved at the end of every super-step, a failure mid-run doesn't take the completed work with it—it's still there in the checkpoints. There's no reason to start over from the beginning. You pick up from where it stopped.
17.3.1) Resuming with invoke(None, config)
Resuming is simple: pass None where the input goes.
graph.invoke(None, config)It means "there is no new input; continue from the saved state." The config still needs a thread_id, of course, since LangGraph has to know which conversation to continue.
Let's stage a failure and resume from it. We'll build a two-node graph whose second node fails only on its first run. It has to succeed on the retry, or we'd never see the resume actually working.
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
class State(TypedDict):
step_1_done: bool
step_2_done: bool
first_try = True # flag to make the first run fail
def step_1(state: State):
print("step_1 running (expensive work)")
return {"step_1_done": True}
def step_2(state: State):
global first_try
if first_try:
first_try = False
print("step_2 failed (API timeout)")
raise RuntimeError("External API timed out")
print("step_2 running")
return {"step_2_done": True}
builder = StateGraph(State)
builder.add_node(step_1)
builder.add_node(step_2)
builder.add_edge(START, "step_1")
builder.add_edge("step_1", "step_2")
builder.add_edge("step_2", END)
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "job-42"}}
try:
graph.invoke({"step_1_done": False, "step_2_done": False}, config)
except RuntimeError as e:
print("Failed:", e)Output:
step_1 running (expensive work)
step_2 failed (API timeout)
Failed: External API timed outstep_1 succeeded and step_2 raised. Let's find out where the graph stopped, using the get_state() we learned in 17.2.
snapshot = graph.get_state(config)
print("next =", snapshot.next)
print("values =", snapshot.values)Output:
next = ('step_2',)
values = {'step_1_done': True, 'step_2_done': False}next is ('step_2',), which tells us the graph stopped in the middle of running step_2. And in values, step_1_done is True—the result of step_1 is still there in the checkpoint.
Now we resume with None.
result = graph.invoke(None, config)
print("Final =", result)Output:
step_2 running
Final = {'step_1_done': True, 'step_2_done': True}step_1 running (expensive work) never printed. step_1 did not run a second time. LangGraph loaded the saved state and picked up at step_2. We didn't pay for that expensive first step twice.
17.3.2) What to Watch Out For When Resuming
When you resume, the node that failed runs again. If that node calls an LLM or hits an external API, those calls happen again too—and they may come back with something different.
That's where the trap is. If step_2 sent an email and then failed, resuming sends a second email. LangGraph only guarantees that it won't re-run the nodes that succeeded.
So any node that might run again needs to be idempotent: doing the same thing twice should leave you in the same place. Check whether the email has already gone out before sending it; put a unique key on the database table so a duplicate insert can't happen.