Python & AI Tutorials Logo
LangChain & LangGraph

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.

python
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 checkpointer

Now 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.

python
# 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.

save state

save state

invoke called

Super-step 1
- node_x

Super-step 2
- node_y
- node_z

Return final state

Checkpointer

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.

  1. Create a checkpointer instance and pass it to compile().
  2. Pass a config containing a thread_id whenever you call invoke().

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.

python
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.

python
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?

python
graph.invoke({"messages": [{"role": "user", "content": "Hello"}]})

Output:

ValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, checkpoint_ns, checkpoint_id

The 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 its checkpointer parameter, and pass a config carrying a thread_id to invoke(). 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.

python
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. bar is ['a', 'b'] because the add reducer accumulated what both nodes returned; foo is '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',) means node_b is still ahead.
  • config — the address of this checkpoint. thread_id identifies the conversation, and checkpoint_id identifies which moment within it. LangGraph assigns the checkpoint_id automatically every time it saves a checkpoint.
  • metadata — bookkeeping about the run. source tells you where the checkpoint came from: "input" means it was built from the input you handed to invoke(), and "loop" means it was produced while the graph was running. step is the super-step number.
  • created_at — when the checkpoint was saved. Handy when you're lining things up against your logs.
  • parent_config — the config of the checkpoint immediately before this one. Follow it and you can walk backward through the run. It's None for the very first checkpoint.
  • tasks — the execution record for the nodes listed in next. 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 in result, and a node that failed leaves its exception in error.
  • 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.

python
snapshot = graph.get_state(config)
 
print(snapshot.values)            # {'foo': 'b', 'bar': ['a', 'b']}
print(snapshot.next)              # ()
print(snapshot.metadata["step"])  # 2

Of 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.

python
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 in values. Getting the input into the state is itself a stage, and that stage hasn't run yet. The __start__ in next is the internal node that does it.

    bar shows 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. foo has 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='' and bar=[] are the values we passed in. node_a is up next.
  • step 1 — the result of running node_a. foo is now 'a' and bar is ['a'], with node_b next.
  • step 2 — the result of running node_b. next is 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.

python
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.

python
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 out

step_1 succeeded and step_2 raised. Let's find out where the graph stopped, using the get_state() we learned in 17.2.

python
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.

python
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.