Persistence & Checkpoints¶
1. Why this matters¶
A stateless graph forgets everything between .invoke() calls. You can't build a chatbot with that — the user says "What about tomorrow?" and the bot has no idea what they're following up on.
Checkpointing makes state durable per thread, which unlocks:
- Persistent conversations keyed by
thread_id. - Crash recovery — process dies, restart, resume.
- Time-travel debugging — list all checkpoints, replay from any one.
- Human-in-the-loop — pause, ask a human, resume.
- Branching alternate runs — fork from a past checkpoint to try a different path.
2. Mental model¶
Think of a checkpointer as the graph's save game mechanism:
flowchart LR
I[Initial state] --> N1[Node 1 runs]
N1 -->|"snapshot saved (thread t1)"| CP1[Checkpoint 1]
N1 --> N2[Node 2 runs]
N2 -->|snapshot| CP2[Checkpoint 2]
N2 --> END([Final])
END -->|next invoke same thread_id| LOAD[Load latest checkpoint]
LOAD --> N3[Continue from there]
Every invocation with the same thread_id:
1. Loads the latest checkpoint as the starting state.
2. Merges your .invoke(...) input on top.
3. Runs, saving checkpoints along the way.
3. Architecture / Flow¶
flowchart TD
A[graph.invoke<br/>thread_id=t1] --> B{Latest checkpoint<br/>for t1?}
B -->|exists| C[Load state from checkpoint]
B -->|none| D[Start fresh from input]
C --> E[Run nodes]
D --> E
E --> F[Save snapshot after each super-step]
F --> G[Final state returned]
F --> H[Checkpointer store<br/>InMemory / SQLite / Postgres]
4. Core concepts¶
- Checkpointer — pluggable backend that persists snapshots.
MemorySaver— RAM, ephemeral. Dev only.SqliteSaver— single-file SQLite. Local apps, single-machine.PostgresSaver— production multi-process. Fromlanggraph-checkpoint-postgres.- Thread — a logical session, identified by
thread_id. State is keyed by thread. - Checkpoint — one snapshot in the thread's history. Each super-step produces one.
config={"configurable": {"thread_id": ...}}— pass on every invoke to scope to a thread.graph.get_state(config)— current state snapshot for a thread.graph.get_state_history(config)— generator over every checkpoint, newest first.graph.update_state(config, values, as_node=...)— surgically modify state mid-run (essential for HITL — see Chapter 10).
5. Code — minimal working example¶
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
from langchain_core.messages import BaseMessage, HumanMessage
from langchain_openai import ChatOpenAI
class ChatState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
llm = ChatOpenAI(model="gpt-4o-mini")
def chat(state: ChatState):
return {"messages": [llm.invoke(state["messages"])]}
b = StateGraph(ChatState)
b.add_node("chat", chat)
b.add_edge(START, "chat")
b.add_edge("chat", END)
graph = b.compile(checkpointer=MemorySaver()) # ← the only change to enable memory
cfg = {"configurable": {"thread_id": "alice-1"}}
# Turn 1
graph.invoke({"messages": [HumanMessage("Hi, I'm Alice.")]}, config=cfg)
# Turn 2 — same thread, remembers Alice
out = graph.invoke({"messages": [HumanMessage("What's my name?")]}, config=cfg)
print(out["messages"][-1].content) # "Your name is Alice."
6. Code — real-world pattern¶
Production-ready setup with SQLite (persists across process restarts):
from langgraph.checkpoint.sqlite import SqliteSaver
import sqlite3
conn = sqlite3.connect("chat_history.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)
graph = builder.compile(checkpointer=checkpointer)
# Different users, fully isolated histories
alice_cfg = {"configurable": {"thread_id": "user_alice"}}
bob_cfg = {"configurable": {"thread_id": "user_bob"}}
Postgres for multi-process / horizontal scaling:
from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://user:pwd@host:5432/dbname?sslmode=require"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup() # create tables (run once)
graph = builder.compile(checkpointer=checkpointer)
# … use graph …
Inspecting state for a thread:
# Current snapshot
snap = graph.get_state(cfg)
print(snap.values) # full state dict
print(snap.next) # which node(s) would run next
print(snap.config) # includes checkpoint_id
# Full history (newest first)
for s in graph.get_state_history(cfg):
print(s.created_at, "→", s.values.get("messages", [])[-1].content[:60])
Time-travel: replay from an earlier checkpoint:
history = list(graph.get_state_history(cfg))
earlier = history[3] # 4 turns back
# Resume from that checkpoint by passing its config
graph.invoke(None, config=earlier.config)
# Or branch: edit and resume
graph.update_state(earlier.config, {"messages": [HumanMessage("Try this instead.")]})
Clear a thread's history (e.g., user clicks "Clear chat"):
# SqliteSaver — delete by thread
conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", ("user_alice",))
conn.commit()
7. Common pitfalls¶
- ❗ Compiling without a checkpointer when you need memory. Then your chatbot forgets after every
.invoke(). - ❗ Forgetting
thread_idin config. Without it, every call effectively starts a fresh "default" thread — state still saves, but isolation breaks. - ❗ Using
MemorySaverin production. Dies with the process. Always use SQLite/Postgres for anything that needs to survive a restart. - ❗ Cross-user state leakage. Always derive
thread_idfrom the authenticated user/session. Never trust a client-supplied thread_id. - ❗ No TTL / cleanup. Threads grow forever. Add periodic cleanup of old
thread_ids. - ❗ Putting large blobs in state. Each super-step writes a full snapshot. Heavy state → slow saves. Keep state lean.
- ❗ Confusing
update_stateparameters.as_node="some_node"tells the engine which node's reducer rules apply to the update — important when reducers are field-specific.
8. When to use vs not use¶
| Use a checkpointer when | Skip when |
|---|---|
| Building a chatbot / agent with memory | Pure one-shot transformation |
| Long-running workflows you may want to resume | All processing fits in a single fast .invoke() |
| Human-in-the-loop is needed | No pause/approve step |
| You want time-travel debugging | — |
| Multi-turn or multi-user app | — |
| Checkpointer | When |
|---|---|
MemorySaver |
Dev / tests only |
SqliteSaver |
Single-machine local app, side projects |
PostgresSaver |
Production, multi-process, horizontal scale |
| Custom (Redis, DynamoDB) | Subclass BaseCheckpointSaver |
9. Cheatsheet¶
# Checkpointers
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.checkpoint.postgres import PostgresSaver
# Compile with one
graph = builder.compile(checkpointer=MemorySaver())
# Always pass thread_id
cfg = {"configurable": {"thread_id": "user_42"}}
graph.invoke({"messages": [...]}, config=cfg)
# Inspect
state = graph.get_state(cfg) # latest snapshot
state.values # full state dict
state.next # tuple of next node names (empty if at END)
state.config # incl. checkpoint_id
# History (newest first)
for snap in graph.get_state_history(cfg):
...
# Time travel — pass an old config
graph.invoke(None, config=old_snap.config)
# Edit state surgically
graph.update_state(cfg, {"messages": [HumanMessage("override")]},
as_node="chat") # apply that node's reducer
# Async variants exist for all of the above: ainvoke, astream, aget_state, ...
10. Q&A — recall test¶
-
Q: What enables a chatbot to remember previous turns? A: Compiling with a checkpointer + invoking with a consistent
thread_idper user. -
Q: What does the engine do when you invoke an already-active thread? A: Loads the latest checkpoint, merges your new input on top, runs, saves new checkpoints.
-
Q: Difference between
MemorySaverandSqliteSaver? A:MemorySaveris RAM-only — state vanishes when the process restarts.SqliteSaverwrites to a file — survives restarts. -
Q: How do you replay from an older state? A: Grab the snapshot from
graph.get_state_history(cfg), invoke with itsconfig(which includes thecheckpoint_id). -
Q: Why scope
thread_idto the authenticated user? A: Otherwise users can see/overwrite each other's history. Always derive thread_id from a server-trusted identity. -
Q: Production checkpointer choice? A:
PostgresSaver. It survives restarts, scales horizontally, supports concurrent processes.
Practice¶
What does this print?
Expected: True
Use a unique thread_id per conversation (not a constant)
Expected: True
Quiz — Quick check¶
What you remember
Q1. What does a checkpointer do?
- Saves graph state after each step, indexed by
thread_id - Checks for errors
- Validates inputs
- Logs metrics
Why: Checkpoints enable resume-after-crash, time travel (replay from any step), and human-in-the-loop (pause now, continue later). Each
thread_idis a separate conversation.
Q2. What's the right checkpointer for production?
- In-memory
-
PostgresSaver(or another persistent store) — survives restarts, scales horizontally - File-based
- No checkpointer
Why: In-memory is fine for development. Production needs persistent storage so conversations survive deploys, restarts, and multi-instance scaling.
Q3. Why does each user need a unique thread_id?
- So their conversation state is isolated from other users
- Required by Postgres
- Performance
- Optional
Why: Without unique IDs, all users share one state, leaking conversations between sessions. Always derive
thread_idfrom the authenticated user (or anonymous session cookie).
Common doubts¶
How big can a checkpoint get?
Bounded by your state size. Each step writes the full state (or a delta, depending on the checkpointer). Keep state small — store IDs, results summaries, not raw documents or LLM context. For huge data, store externally and reference by ID in state.
Can I resume a conversation from days ago?
Yes — that's exactly what persistent checkpointers enable. Reload the graph with the same thread_id and call .invoke(None, config=...) to continue from the last checkpoint. Cross-day, cross-process, cross-deploy.
How do I handle checkpoint schema migrations?
Same as any database. If you change the state schema, old checkpoints might break. Strategies: (1) add new fields with defaults, (2) version your state schema, (3) write a migration script. Treat checkpoint data like production database data.