LangGraph Thread ID & Checkpoint Retrieval Failures
You resume a thread and get an empty state, or a wrong checkpoint, or a mysterious "None" instead of the messages you saved. These are the checkpoint-retrieval failure modes — always caused by a mismatch between what you saved and what you're asking for.
By Ahmed R. · Last updated Aug 14, 2026 · LangGraph · Page #129
thread_id you pass doesn't match what was saved, when the checkpointer instance is different from the one that saved, or when you request a specific checkpoint_id that isn't in that thread. Fix: hold thread_id as a stable string (session cookie, user_id+chat_id), share the checkpointer across processes via Postgres, and use app.get_state(config) to inspect what's actually there.Real error messages you'll see
# app.get_state(config) returns None instead of the expected state
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver() # <-- new instance every process
app = build_graph(checkpointer)
result = app.get_state({"configurable": {"thread_id": "user-42"}})
print(result) # None — the thread was saved by a different MemorySaver instance in a prior process.
ValueError: Checkpoint not found for thread 'user-42' at checkpoint_id '1ef8a3b2-...'
at Pregel.get_state()
# You passed both thread_id and checkpoint_id but the specific checkpoint isn't in this thread. Either the ID is wrong or it was pruned.
# get_state succeeds, state.values["messages"] == []
# Usually the initial input was invoked WITHOUT thread_id, so a fresh thread was created at that random default.
# Then subsequent calls with a real thread_id find an empty checkpoint.
Thread inspection API cheat sheet
| Method | Returns | Use for |
|---|---|---|
app.get_state(config) | Latest StateSnapshot for the thread, or None | Read current state; check if thread exists |
app.get_state_history(config) | Iterator of all snapshots (newest first) | Time travel; audit; find a specific checkpoint |
checkpointer.list(config) | Lower-level iterator of checkpoint tuples | Debugging; bulk operations |
app.update_state(config, values) | New checkpoint_id | Manually inject state before resume |
app.invoke(state, config) with checkpoint_id in config | Resumes from that specific checkpoint | Time-travel resume; replay from a point |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 25%Different checkpointer instance across processes.
MemorySaveris per-process. A worker restart or a second gunicorn worker sees an empty saver. UseSqliteSaverorPostgresSaver. - 19%Thread ID drift. Client sends
"user-42"one call and"user_42"the next. Or you generate a fresh UUID inside the request handler instead of persisting one. Every call opens a new thread. - 15%Requesting a specific
checkpoint_idthat doesn't exist. Copy-pasted from a stale log, or the checkpoint was pruned by TTL. - 11%Initial invoke missed the config. First call omitted
thread_id, then subsequent calls include it — the first turn landed in an anonymous thread; the "resume" reads an empty new thread. - 10%Cross-thread bleed via shared state variable. A global cache or shared object stores state; retrieval returns the last thread's data regardless of thread_id. Not a LangGraph bug — an app-level bug.
- 8%Filter by
checkpoint_nsmismatch. Subgraphs write to namespaced checkpoints. Reading from the root namespace returns nothing when data lives under"child_graph:node". - 7%get_state called before any invoke. Thread has never been written to; retrieval returns
None. Expected behavior, often mistaken for a bug. - 5%Read replica lag. Writing to primary Postgres, reading from a replica for checkpoint retrieval. Replica catches up in seconds but retrieval right after write may miss.
How to fix it
Persist thread_id at the session boundary, never inside the request
Fixes "every call starts a new thread".
The thread_id must survive across requests. Store it in the session cookie, the user record, or the URL — anywhere that outlives a single HTTP call. Generating it inside your handler is the most common bug: every call gets a fresh UUID, every call starts a new thread, no chat history ever accumulates.
from fastapi import FastAPI, Cookie, Response
from langchain_core.messages import HumanMessage
import uuid
api = FastAPI()
# ❌ ANTI-PATTERN — new thread every request
@api.post("/chat-broken")
async def chat_broken(message: str):
thread_id = str(uuid.uuid4()) # <-- fresh UUID every call
config = {"configurable": {"thread_id": thread_id}}
return await api.state.graph.ainvoke(
{"messages": [HumanMessage(content=message)]},
config,
) # No history ever — every turn is turn 1
# ✅ Correct — thread_id from a persistent cookie
@api.post("/chat")
async def chat(
response: Response,
message: str,
session_id: str | None = Cookie(default=None),
):
if not session_id:
# First request — mint a session and set the cookie
session_id = str(uuid.uuid4())
response.set_cookie("session_id", session_id, httponly=True, max_age=3600 * 24 * 30)
config = {"configurable": {"thread_id": session_id}}
return await api.state.graph.ainvoke(
{"messages": [HumanMessage(content=message)]},
config,
)
# ✅ Better — thread_id derives from user + chat_id, stored in your DB
@api.post("/chat/{chat_id}")
async def chat_by_id(chat_id: str, user_id: str, message: str):
# thread_id is deterministic and portable across servers
thread_id = f"{user_id}::{chat_id}"
config = {"configurable": {"thread_id": thread_id}}
return await api.state.graph.ainvoke(
{"messages": [HumanMessage(content=message)]},
config,
)
# ✅ Check thread state before invoking (useful for "resume vs new" UI)
@api.get("/chat/{chat_id}/history")
async def get_history(chat_id: str, user_id: str):
config = {"configurable": {"thread_id": f"{user_id}::{chat_id}"}}
state = await api.state.graph.aget_state(config)
if state is None:
return {"exists": False, "messages": []}
return {
"exists": True,
"checkpoint_id": state.config["configurable"]["checkpoint_id"],
"messages": [
{"role": m.type, "content": m.content} for m in state.values.get("messages", [])
],
}
user_id::chat_id) are easier to debug than UUIDs — you can grep logs, look up specific threads in Postgres, and give support engineers a way to find a user's conversation. Only use random UUIDs when the thread is genuinely anonymous.Share the checkpointer across processes — use Postgres or SQLite, not MemorySaver
Fixes get_state returning None after restart or in a second worker.
MemorySaver lives in one process. Worker restart = data gone. Second worker = separate empty saver. This is the second most common "why is my history gone" cause after thread_id drift. Move to a shared backend and every worker sees the same checkpoints.
# ✅ Postgres — every worker and every process shares the same store
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from fastapi import FastAPI
from contextlib import asynccontextmanager
import os
@asynccontextmanager
async def lifespan(app: FastAPI):
dsn = os.environ["DATABASE_URL"].replace("postgres://", "postgresql://", 1)
pool = AsyncConnectionPool(dsn, min_size=2, max_size=20)
await pool.open()
checkpointer = AsyncPostgresSaver(pool)
await checkpointer.setup() # idempotent
app.state.graph = build_graph(checkpointer)
yield
await pool.close()
api = FastAPI(lifespan=lifespan)
# ✅ SQLite — works across restarts (single writer, single process only)
from langgraph.checkpoint.sqlite import SqliteSaver
@asynccontextmanager
async def lifespan_sqlite(app: FastAPI):
import sqlite3
conn = sqlite3.connect("/var/data/langgraph.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)
checkpointer.setup()
app.state.graph = build_graph(checkpointer)
yield
conn.close()
# ✅ MemorySaver — ONLY for tests / single-process notebooks
from langgraph.checkpoint.memory import MemorySaver
if __name__ == "__main__" and os.environ.get("MODE") == "test":
checkpointer = MemorySaver()
graph = build_graph(checkpointer)
# ... tests run here, data lost when process exits ...
# ✅ Inspect what's in Postgres for a thread — useful when debugging "None"
async def debug_thread(thread_id: str):
async with AsyncPostgresSaver.from_conn_string(os.environ["DATABASE_URL"]) as saver:
config = {"configurable": {"thread_id": thread_id}}
count = 0
async for tup in saver.alist(config):
count += 1
print(f"checkpoint {tup.checkpoint['id']}: {tup.metadata.get('step')}")
if count == 0:
print(f"No checkpoints for thread {thread_id!r} — check the ID.")
MemorySaver to Postgres in production, do it in a maintenance window. Existing in-memory checkpoints don't move — every user's chat history resets on the deploy. Warn them or serialize state to Postgres in a shutdown hook first.Use get_state_history to time-travel and get_state to inspect
The right APIs to debug missing state.
When retrieval returns nothing, use the introspection APIs to see what's actually there. get_state(config) returns the latest snapshot, get_state_history(config) returns every historical snapshot, and checkpointer.list(config) gives raw checkpoint tuples. Together they tell you whether the thread is empty, whether the ID is wrong, or whether a specific checkpoint is missing.
# ✅ Get the latest state snapshot for a thread
config = {"configurable": {"thread_id": "user-42"}}
snapshot = app.get_state(config)
if snapshot is None:
print(f"Thread {config['configurable']['thread_id']} has no checkpoints.")
else:
print("Latest checkpoint:", snapshot.config["configurable"]["checkpoint_id"])
print("Values:", snapshot.values)
print("Next nodes to run:", snapshot.next)
print("Metadata:", snapshot.metadata)
# ✅ Walk history — newest first, one snapshot per graph step
for snap in app.get_state_history(config):
print(
snap.config["configurable"]["checkpoint_id"],
"step:", snap.metadata.get("step"),
"source:", snap.metadata.get("source"), # "input" | "loop" | "update"
"next:", snap.next,
)
# ✅ Time-travel — resume from a SPECIFIC historic checkpoint
# 1. Get history and pick a checkpoint from before the buggy turn
history = list(app.get_state_history(config))
target = next(s for s in history if s.metadata.get("step") == 5)
# 2. Build a config that points at that specific checkpoint
resume_config = {
"configurable": {
"thread_id": "user-42",
"checkpoint_id": target.config["configurable"]["checkpoint_id"],
},
}
# 3. Invoke — the graph resumes from that point, creating a new branch
result = app.invoke(None, resume_config) # None = don't add new input, just resume
# ✅ Manually update state at a specific checkpoint (correct a bad message)
from langchain_core.messages import AIMessage, RemoveMessage
new_checkpoint_id = app.update_state(
resume_config,
{"messages": [RemoveMessage(id="msg_bad_id")]},
)
print("Wrote correction at:", new_checkpoint_id)
# ✅ List checkpoints filtered by metadata (LangGraph 0.2+)
recent = app.get_state_history(
config,
limit=5,
filter={"source": "input"}, # only user-input checkpoints
)
StateSnapshot also carries a next field — the nodes queued to run when the thread resumes. On an interrupted human-in-the-loop graph, next tells you which node is paused waiting for input.Prevention checklist
- Store
thread_idin a place that outlives a single request: session cookie, user record, or URL segment. - Never mint a UUID inside a request handler — that guarantees every call starts a new thread.
- Use a persistent checkpointer (
SqliteSaver,PostgresSaver) in production.MemorySaveris tests only. - All processes / workers must share the same checkpointer backend. Single-writer SQLite = one process; Postgres = many.
- Use
get_state(config)before showing "resume conversation" UI — aNonereturn means the thread is empty. - For time-travel resume, pass
checkpoint_idin the config alongsidethread_id. - When retrieval returns nothing, walk
get_state_historyto confirm what's actually saved before assuming a bug.
Frequently asked questions
Either the invoke was passed a different thread_id than get_state, or the checkpointer is a different instance (common with MemorySaver across processes), or the invoke failed silently before writing a checkpoint. Check that both calls use identical config, and that the checkpointer is a shared backend.
thread_id groups all checkpoints belonging to one conversation. checkpoint_id identifies a specific point in that conversation's history — one per graph step. You pass thread_id alone to resume the latest state, or both to time-travel back to a specific step. Every invoke creates one or more new checkpoint_ids under the same thread_id.
Yes, if you're using a shared checkpointer (PostgresSaver, or SqliteSaver with a shared file). Pass the same thread_id in config and the graph resumes exactly where the other process left off. This is how multi-region and blue/green deploys work — new pods pick up existing threads from the shared backend.
Not by LangGraph itself. Every graph step writes a new checkpoint and old ones persist forever unless you delete them. For high-volume apps, add a nightly cleanup: DELETE FROM checkpoints WHERE ts < NOW() - INTERVAL '30 days', or delete on thread completion. This is app-level policy, not a framework feature.
Creates a new branch. update_state writes a new checkpoint whose parent is the checkpoint pointed to by the config. Prior checkpoints are untouched; the "current" pointer now sees the new checkpoint as the head. This is what powers safe time-travel: you can rewind, correct, and resume without destroying the original history.