Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Persist LangGraph agent state correctly with MemorySaver and PostgresSaver — thread_id discipline, JSON-serializable state rules, time-travel, schema migration. Use when adding chat memory, migrating from ConversationBufferMemory, or time-traveling an agent state to debug an incident. Trigger with "langgraph checkpointer", "MemorySaver", "PostgresSaver", "thread_id", "langgraph time travel", "langgraph state persistence".
.claude/skills/jeremylongshore-langchain-langgraph-checkpointing/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 145% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 118% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 109% | 0% |
| case-18 | ✓→✓ | = Same ✓ | 160% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 428% | 0% |
A chat agent that "keeps introducing itself" is almost always P16. The caller invokes graph.invoke(state) without passing config={"configurable": {"thread_id": ...}} — LangGraph's checkpointer silently spawns a fresh state per call. No error, no warning, no log line. The user sees it; the code does not.
That is one of five separate checkpointing pitfalls this skill covers:
thread_id silently resets memoryinterrupt_before raises TypeError when state holds non-JSON values(datetime, Decimal, custom classes) — and it raises at the interrupt boundary, not when the bad value was first assigned, so the traceback points at the wrong line
PostgresSaver does not auto-migrate checkpoint schema; upgradinglanggraph silently reads old checkpoints as empty state
ConversationBufferMemory and the rest of legacy chat memory wereremoved in LangChain 1.0; checkpointers are the replacement
state["files"] grows unboundedlyand eventually makes checkpoint writes a latency hotspot
This skill walks through picking a checkpointer by environment, enforcing thread_id at the application boundary, constraining state to JSON-safe primitives, Postgres setup + migration, and time-travel for incident debugging. Pinned to langgraph >= 1.0, < 2.0, langgraph-checkpoint-postgres >= 1.0, < 2.0. Pain-catalog anchors: P16, P17, P18, P20, P22, P40, P51.
pip install langgraph langchain-core (both >= 1.0, < 2.0)pip install langgraph-checkpoint-postgres and a Postgres 13+instance
asyncpgthread_id strategy — typically a UUID4 string per conversation; seethread-id-discipline.md
| Env | Checkpointer | Import | |---|---|---| | Dev, tests, notebooks | MemorySaver | langgraph.checkpoint.memory | | Single-host CLI / desktop | SqliteSaver | langgraph.checkpoint.sqlite | | Staging, prod (sync) | PostgresSaver | langgraph.checkpoint.postgres | | Staging, prod (async / FastAPI) | AsyncPostgresSaver | langgraph.checkpoint.postgres.aio |
MemorySaver is in-process only. State vanishes on restart. Every worker has its own (P22 analog for LangGraph). Use it anywhere state loss is acceptable; never in a multi-worker web backend.
PostgresSaver and its async sibling require setup() on every startup and after every `langgraph` upgrade (see Step 5). Checkpoint storage overhead is typically 1-10 KB per step of serialized state; plan your DB size accordingly — a 2,000-turn conversation with 3 KB average state fits in ~6 MB per thread.
See checkpointer-comparison.md for the full matrix including latency, concurrency, and the FastAPI lifespan pattern.
thread_id at every invocationThis is the fail-loud middleware that prevents P16:
pythonfrom typing import Any def require_thread_id(config: dict[str, Any]) -> dict[str, Any]: """Raise if thread_id is missing. Fails loud so P16 surfaces in tests, not in user-visible conversation logs.""" configurable = (config or {}).get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError( "thread_id missing from config['configurable']. " "Every graph invocation must carry a thread_id." ) if not isinstance(thread_id, str): raise TypeError( f"thread_id must be str (UUID), got {type(thread_id).__name__}" ) return config
Call it at every application boundary:
pythonimport uuid config = {"configurable": {"thread_id": str(uuid.uuid4())}} require_thread_id(config) result = graph.invoke(initial_state, config=config)
For web endpoints, extract to a FastAPI dependency (Header(...) with no default — forces 422 on missing). For multi-tenant apps, scope the thread id by composing tenant + user + conversation ids into a single colon-delimited string (example: "acme:alice:conv-1"). See thread-id-discipline.md for UUID generation, rotation, and the integration test that proves tenants do not share state.
pythonfrom typing import Annotated, TypedDict from langgraph.graph.message import add_messages from langchain_core.messages import AnyMessage class AgentState(TypedDict): # Messages are safe — LangGraph registers a custom serializer. messages: Annotated[list[AnyMessage], add_messages] # Primitives only below. NO datetime, NO Decimal, NO custom classes. user_id: str turn_count: int last_action_at: str # ISO string, not datetime.datetime pending_approval: bool metadata: dict[str, str] # dict keys must be str plan: list[dict[str, str]] # list of primitive dicts
The rule: state fields must be JSON-safe primitives or recursive structures of them (str, int, float, bool, None, list, dict[str, ...]). json.dumps(state) must succeed. If it raises, the checkpointer raises — often at a HITL interrupt many steps later (P17), which is why the traceback never points at the line that introduced the bad value.
For non-primitive inputs, coerce at node output boundaries with a helper:
pythonfrom datetime import datetime from decimal import Decimal def record_purchase(state: AgentState) -> dict: now = datetime.utcnow() price = Decimal("19.99") return { "last_action_at": now.isoformat(), "metadata": {**state["metadata"], "price": str(price)}, }
Forbidden-types reference and the full to_state / from_state helper pair are in json-serializability-rules.md.
pythonfrom langgraph.checkpoint.postgres import PostgresSaver from langgraph.graph import StateGraph import os DB_URI = os.environ["DATABASE_URL"] def build_graph() -> StateGraph: builder = StateGraph(AgentState) builder.add_node("agent", agent_node) builder.add_node("human_approval", human_approval_node) builder.set_entry_point("agent") builder.add_edge("agent", "human_approval") builder.set_finish_point("human_approval") return builder with PostgresSaver.from_conn_string(DB_URI) as checkpointer: checkpointer.setup() # Idempotent. Creates checkpoint tables if missing. graph = build_graph().compile( checkpointer=checkpointer, interrupt_before=["human_approval"], ) config = {"configurable": {"thread_id": "user-123"}} require_thread_id(config) result = graph.invoke({"messages": [HumanMessage("hi")]}, config=config)
For async, mirror the pattern with AsyncPostgresSaver.from_conn_string(...) inside a FastAPI @asynccontextmanager lifespan; every call site uses await graph.ainvoke(...).
setup() on startup AND after every langgraph upgradeP20 is the quiet one: you pip install --upgrade langgraph, tests pass, CI goes green, you deploy. Existing threads come back empty. No DB error.
python# Put this in your deploy script / migration runbook: with PostgresSaver.from_conn_string(DB_URI) as checkpointer: checkpointer.setup() # Sanity check: read one known thread and assert it's not empty. snap = checkpointer.get({"configurable": {"thread_id": "canary-thread"}}) assert snap is not None, "Canary thread lost after schema migration"
Run this in staging first, with a canary thread whose state you pre-populated from an older langgraph version. If the assertion holds, promote. If not, the migration path is: dump checkpoints, upgrade, restore via a migration script. Do not promote to production on a version bump without this check.
Every checkpoint is keyed by (thread_id, checkpoint_id) and reachable via graph.get_state_history(config):
pythonconfig = {"configurable": {"thread_id": bad_thread_id}} history = list(graph.get_state_history(config)) for i, snap in enumerate(history): print(i, snap.metadata.get("step"), snap.next, list(snap.values.keys())) # Resume from a specific prior checkpoint — None as input = "use this state": past = history[3] # history is newest-first result = graph.invoke(None, config=past.config) # past.config pins checkpoint_id
To fix state and replay:
pythongraph.update_state( past.config, {"retry_count": 0, "error_reason": None}, as_node="validator", ) graph.invoke(None, config=past.config)
The original branch is preserved — update_state creates a new checkpoint alongside the old one. Useful for forensics and for A/B comparing an old vs new model on the exact same state.
See time-travel-and-replay.md for the full incident playbook, branching for A/B eval, and checkpoint pruning SQL.
require_thread_id middleware that fails loud on missing thread_id, andat least one integration test that asserts two tenants do not share state
AgentState TypedDict constrained to JSON-safe primitives, plus ato_state helper at node output boundaries for datetime / Decimal / Pydantic / enums
PostgresSaver.setup() wired into startup and every deploy that bumpslanggraph
get_state_history + update_state totime-travel an agent state, with prior branches preserved
| Error | Cause | Fix | |-------|-------|-----| | Agent forgets every turn, no error logged | Missing thread_id (P16) | Add require_thread_id middleware at app boundary; FastAPI Header(...) with no default | | TypeError: Object of type datetime is not JSON serializable at a HITL pause | Non-JSON value in state (P17) | Coerce at node output: dt.isoformat(), str(decimal), model.model_dump(mode="json") | | Upgrading langgraph returns empty state for existing threads | Checkpoint schema drift (P20) | PostgresSaver.setup() after every upgrade; canary-thread assertion in staging before prod | | ImportError: cannot import name 'ConversationBufferMemory' | Legacy memory removed in 1.0 (P40) | Migrate to MemorySaver/PostgresSaver + thread_id per conversation | | Checkpoint writes take 500+ ms on Deep Agent runs | Unbounded state["files"] growth (P51) | Add cleanup node that prunes state["files"] older than N steps | | Command(update={"messages": [x]}) erases prior messages | Missing reducer (P18) | messages: Annotated[list[AnyMessage], add_messages] | | Mixed graph.invoke with AsyncPostgresSaver | Sync call on async saver | Use await graph.ainvoke(...) everywhere, or switch to sync PostgresSaver |
MemorySaverDev-mode multi-turn chat that survives within a single process. Good for local iteration and pytest fixtures; state vanishes on restart.
pythonfrom langgraph.checkpoint.memory import MemorySaver from langgraph.prebuilt import create_react_agent from langchain_anthropic import ChatAnthropic checkpointer = MemorySaver() agent = create_react_agent( model=ChatAnthropic(model="claude-sonnet-4-6"), tools=[...], checkpointer=checkpointer, ) config = {"configurable": {"thread_id": "dev-session-1"}} agent.invoke({"messages": [HumanMessage("hi")]}, config=config) agent.invoke({"messages": [HumanMessage("remember my name is alice")]}, config=config) agent.invoke({"messages": [HumanMessage("what's my name?")]}, config=config) # alice
Async production shape. One AsyncPostgresSaver pool, thread-id extracted from a required header, tenants isolated by a composite key.
See checkpointer-comparison.md for the complete FastAPI lifespan pattern and pool sizing advice (max_size needs to exceed concurrent graph count, and the LangGraph pool should be separate from the application's primary DB pool).
ConversationBufferMemory (P40)Legacy 0.x pattern replaced end-to-end:
python# OLD — raises ImportError on 1.0: # from langchain.memory import ConversationBufferMemory # memory = ConversationBufferMemory() # chain = LLMChain(llm=llm, memory=memory) # NEW: from langgraph.prebuilt import create_react_agent from langgraph.checkpoint.postgres import PostgresSaver with PostgresSaver.from_conn_string(DB_URI) as cp: cp.setup() agent = create_react_agent(model=llm, tools=tools, checkpointer=cp) config = {"configurable": {"thread_id": user_session_id}} agent.invoke({"messages": [HumanMessage(user_input)]}, config=config)
The thread_id is now the "session key" that ConversationBufferMemory used to carry implicitly.
Production returned a wrong answer for thread_id=bad-thread at 14:03. Walk history newest-first, inspect the prior state, patch and replay:
pythonconfig = {"configurable": {"thread_id": "bad-thread"}} history = list(graph.get_state_history(config)) for i, snap in enumerate(history): print(i, snap.metadata.get("step"), snap.next) # Assume step 7 wrote the bad output; step 6 is the input. prior = history[-7] # or index by metadata["step"] print("input to bad node:", prior.values) graph.update_state(prior.config, {"retry_count": 0}, as_node="validator") graph.invoke(None, config=prior.config) # new branch with correct result
Full playbook (finding thread_id in logs, inspecting writes metadata, pruning history) in time-travel-and-replay.md.
docs/pain-catalog.md (entries P16, P17, P18, P20, P22, P40, P51)langchain-langgraph-basics, langchain-langgraph-agents, langchain-upgrade-migration, langchain-common-errors| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-18 | pass→pass | 13,204 | 12,063 | -9% | 1 | 1 | 0% | 2,444 | 6,349 | +160% | 0 | 0 | — |
case-01 | fail→pass | 15,527 | 20,614 | +33% | 1 | 1 | 0% | 3,017 | 7,399 | +145% | 0 | 0 | — |
case-02 | fail→fail | 16,511 | 24,130 | +46% | 1 | 1 | 0% | 3,252 | 7,722 | +137% | 0 | 0 | — |
case-03 | pass→pass | 5,416 | 10,420 | +92% | 1 | 1 | 0% | 910 | 4,802 | +428% | 0 | 0 | — |
case-04 | pass→pass | 24,873 | 22,578 | -9% | 1 | 1 | 0% | 2,948 | 6,781 | +130% | 0 | 0 | — |
case-05 | pass→pass | 18,926 | 19,203 | +1% | 1 | 1 | 0% | 2,703 | 6,855 | +154% | 0 | 0 | — |
case-06 | fail→pass | 22,031 | 12,393 | -44% | 1 | 1 | 0% | 2,924 | 6,360 | +118% | 0 | 0 | — |
case-07 | pass→pass | 16,809 | 21,236 | +26% | 1 | 1 | 0% | 2,284 | 6,643 | +191% | 0 | 0 | — |
case-08 | pass→pass | 14,494 | 15,026 | +4% | 1 | 1 | 0% | 2,079 | 5,880 | +183% | 0 | 0 | — |
case-09 | pass→pass | 19,840 | 17,700 | -11% | 1 | 1 | 0% | 2,680 | 6,500 | +143% | 0 | 0 | — |
case-10 | pass→pass | 19,785 | 23,444 | +18% | 1 | 1 | 0% | 2,672 | 7,332 | +174% | 0 | 0 | — |
case-11 | pass→pass | 13,372 | 10,836 | -19% | 1 | 1 | 0% | 1,512 | 6,283 | +316% | 0 | 0 | — |
case-12 | fail→pass | 25,171 | 19,998 | -21% | 1 | 1 | 0% | 3,845 | 8,046 | +109% | 0 | 0 | — |
case-13 | pass→pass | 9,468 | 5,778 | -39% | 1 | 1 | 0% | 1,402 | 5,075 | +262% | 0 | 0 | — |
case-14 | pass→pass | 10,272 | 10,152 | -1% | 1 | 1 | 0% | 911 | 4,936 | +442% | 0 | 0 | — |
case-15 | fail→fail | 16,025 | 10,913 | -32% | 1 | 1 | 0% | 1,782 | 5,898 | +231% | 0 | 0 | — |
case-16 | pass→pass | 17,945 | 10,043 | -44% | 1 | 1 | 0% | 1,818 | 5,898 | +224% | 0 | 0 | — |
case-17 | pass→pass | 39,129 | 10,601 | -73% | 1 | 1 | 0% | 1,696 | 5,785 | +241% | 0 | 0 | — |
case-19 | pass→pass | 14,396 | 11,211 | -22% | 1 | 1 | 0% | 1,744 | 5,168 | +196% | 0 | 0 | — |
case-20 | pass→pass | 15,785 | 10,401 | -34% | 1 | 1 | 0% | 1,806 | 5,826 | +223% | 0 | 0 | — |
case-21 | pass→pass | 13,365 | 15,645 | +17% | 1 | 1 | 0% | 2,835 | 6,290 | +122% | 0 | 0 | — |
case-22 | pass→pass | 9,882 | 10,318 | +4% | 1 | 1 | 0% | 1,846 | 5,799 | +214% | 0 | 0 | — |
case-23 | pass→pass | 14,079 | 4,111 | -71% | 1 | 1 | 0% | 2,535 | 4,770 | +88% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +13 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.