LangGraph Thread ID & Checkpoint Retrieval Failures — Fix Guide (2026)
Checkpointing · Thread & Retrieval Severity: High

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.

TL;DRCheckpoint retrieval fails when the 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

get_state returns None
get_state returns None
# 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
ValueError — checkpoint not found
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.
State returned but messages are empty
State returned but messages are empty
# 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

MethodReturnsUse for
app.get_state(config)Latest StateSnapshot for the thread, or NoneRead 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 tuplesDebugging; bulk operations
app.update_state(config, values)New checkpoint_idManually inject state before resume
app.invoke(state, config) with checkpoint_id in configResumes from that specific checkpointTime-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. MemorySaver is per-process. A worker restart or a second gunicorn worker sees an empty saver. Use SqliteSaver or PostgresSaver.
  • 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_id that 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_ns mismatch. 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

Fix #1

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.

thread_id_persistence.pypython
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", [])
        ],
    }
Note: Deterministic thread IDs (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.
Fix #2

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.

shared_checkpointer.pypython
# ✅ 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.")
Note: When migrating from 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.
Fix #3

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.

introspect_and_travel.pypython
# ✅ 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
)
Note: The 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_id in 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. MemorySaver is 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 — a None return means the thread is empty.
  • For time-travel resume, pass checkpoint_id in the config alongside thread_id.
  • When retrieval returns nothing, walk get_state_history to confirm what's actually saved before assuming a bug.

Frequently asked questions

Why does get_state return None immediately after invoke?

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.

What's the difference between thread_id and checkpoint_id?

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.

Can I resume a thread from a completely different process?

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.

Are checkpoints garbage-collected?

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.

Does update_state create a new branch or overwrite?

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.

Related errors