LangGraph Persistence Part 2: PostgresSavers, HITL, and Production Backends
Move LangGraph from InMemorySaver to PostgresSaver with PostgreSQL pools and async savers, compare SQLite and Redis, and run interrupt and resume in prod.
This article is published in English.
From RAM checkpoints to durable backends
Part one covered why LangGraph persistence exists—state, checkpoints, checkpointers, threads, and InMemorySaver. This part makes the same ideas production-ready. In-memory savers vanish on process restart; real agents need storage that outlives the Python process.
PostgresSaver for production durability
A database sits outside the app process. Crashes, redeploys, and machine swaps leave checkpoint history intact. PostgresSaver writes those checkpoints into PostgreSQL instead of RAM.
PostgreSQL is a common choice because it is durable, widely offered as managed cloud services, safe for concurrent app servers, and capable of growing with checkpoint volume as traffic rises.
Install the dedicated package:
pip install langgraph-checkpoint-postgres
Basic wiring:
from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://user:password@localhost:5432/mydb"with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
# This creates the required tables the first time you use it.
# Only needs to run once per database.
checkpointer.setup() graph = builder.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": "session-99"}}
result = graph.invoke(
{"messages": [{"role": "user", "content": "Hello"}]},
config=config,
)
from_conn_string opens a context-managed connection. setup() creates tables such as checkpoints and checkpoint_blobs once—treat it like a migration, not a per-request ritual. compile(checkpointer=...) is the same API as the in-memory path; swapping backends does not rewrite the graph.
Connection pools
Opening a fresh connection per request wastes time. Prefer a pool of ready connections:
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver
DB_URI = "postgresql://user:password@localhost:5432/mydb"with ConnectionPool(DB_URI) as pool:
checkpointer = PostgresSaver(pool)
checkpointer.setup() # one-time setup
graph = builder.compile(checkpointer=checkpointer)
Async variant
FastAPI-style apps should use AsyncPostgresSaver:
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
await checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
If you hand-build psycopg connections, set autocommit=True and row_factory=dict_row or setup() may not persist tables. Keep thread_id under 255 characters; UUIDs are a safe default.
Other backends
SQLite
from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("./agent_memory.db") as checkpointer:
graph = builder.compile(checkpointer=checkpointer)
A single local file survives restarts and suits prototypes or one-process scripts. File locking makes multi-worker web deployments a poor fit.
Redis
Fast, memory-first with optional disk persistence—useful for high-throughput chat products with many simultaneous threads.
Managed cloud Postgres
RDS, Cloud SQL, Supabase, Neon, and peers speak standard PostgreSQL, so PostgresSaver works while the provider handles backups and uptime.
Custom checkpointers
Subclass BaseCheckpointSaver and implement put, get_tuple, list (plus async counterparts) when internal storage is mandatory.
Choosing quickly
Local durable script → SQLite. Multi-worker production → Postgres/AsyncPostgres. Extreme concurrent checkpoint traffic → Redis. Existing managed Postgres → cloud-hosted Postgres. Forced internal store → custom saver.
Five progressive examples
Example 1 — no persistence (amnesia)
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
count: intdef increment(state: State) -> dict:
return {"count": state["count"] + 1}builder = StateGraph(State)
builder.add_node("increment", increment)
builder.add_edge(START, "increment")
builder.add_edge("increment", END)graph = builder.compile() # No checkpointer passed in!print(graph.invoke({"count": 0})) # {'count': 1}
print(graph.invoke({"count": 0})) # {'count': 1} again — no memory of the last call!
{'count': 1}
{'count': 1}
The second invoke cannot see the first: no checkpointer, no thread id.
Example 2 — InMemorySaver continuity
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
class State(TypedDict):
count: intdef increment(state: State) -> dict:
return {"count": state["count"] + 1}builder = StateGraph(State)
builder.add_node("increment", increment)
builder.add_edge(START, "increment")
builder.add_edge("increment", END)checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)config = {"configurable": {"thread_id": "counter-1"}}print(graph.invoke({"count": 0}, config)) # {'count': 1}
print(graph.invoke({"count": 0}, config)) # {'count': 2}
{'count': 1}
{'count': 2}
Same inputs now continue from the prior checkpoint instead of resetting.
Example 3 — resume after a simulated crash
from langgraph.checkpoint.memory import InMemorySaver
# Shared storage dictionary simulates a database that survives across "restarts"
shared_storage = {}checkpointer_1 = InMemorySaver()
checkpointer_1.storage = shared_storage # pretend this is durable storagegraph = builder.compile(checkpointer=checkpointer_1)
config = {"configurable": {"thread_id": "resume-demo"}}graph.invoke({"count": 0}, config) # count becomes 1
print("Process 'crashes' here...")# Simulate a restart: a brand new checkpointer object, same underlying storage
checkpointer_2 = InMemorySaver()
checkpointer_2.storage = shared_storage
graph_after_restart = builder.compile(checkpointer=checkpointer_2)# Resume using the SAME thread_id
result = graph_after_restart.invoke({"count": 0}, config)
print(result) # {'count': 2} — continued, did NOT reset to 0
Sharing an in-process dict fakes restart; production simply points a new process at the same Postgres database and resumes by thread_id.
Example 4 — human-in-the-loop with interrupt()
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
class State(TypedDict):
task: str
approved: booldef request_approval(state: State) -> dict:
decision = interrupt(f"Approve this task? '{state['task']}' (yes/no)")
return {"approved": decision == "yes"}builder = StateGraph(State)
builder.add_node("request_approval", request_approval)
builder.add_edge(START, "request_approval")
builder.add_edge("request_approval", END)checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)config = {"configurable": {"thread_id": "approval-1"}}# Step 1: Start the graph — it will pause at interrupt()
result = graph.invoke({"task": "Send $500 refund", "approved": False}, config)
print(result) # Contains an '__interrupt__' key with our question# ... hours later, a human reviews and approves ...# Step 2: Resume with the human's answer
final_result = graph.invoke(Command(resume="yes"), config)
print(final_result) # {'task': 'Send $500 refund', 'approved': True}
interrupt(...) pauses and checkpoints. The caller sees a payload under __interrupt__. Later, Command(resume=...) continues. On resume LangGraph re-runs the whole node from the top—keep side effects after the interrupt, not before.
Example 5 — Postgres-backed chatbot
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver
class State(TypedDict):
messages: listdef chatbot_node(state: State) -> dict:
last_message = state["messages"][-1]
reply = f"You said: {last_message}"
return {"messages": state["messages"] + [reply]}builder = StateGraph(State)
builder.add_node("chatbot", chatbot_node)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)DB_URI = "postgresql://user:password@localhost:5432/mydb"with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
checkpointer.setup() # run once
graph = builder.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": "user-42"}}
result = graph.invoke({"messages": ["Hi there"]}, config)
print(result)
{'messages': ['Hi there', 'You said: Hi there']}
Stop the script, start it tomorrow with the same thread id, and the conversation continues because the database holds the checkpoints.
Why HITL depends on persistence
Three pieces: interrupt() to pause with a payload, indefinite waiting with zero CPU while state sits in storage, and Command(resume=...) to continue. Without a checkpointer, pause-and-wait has nowhere to store the frozen graph.
Real approval patterns—loan decisions above a threshold, clinical suggestions before charting, legal clause review, expense overages—often wait minutes to weeks. Persistence makes that wait cheap and safe.
Persistence across agent capabilities
Long conversations, multi-step plans that survive crashes, tool loops that must not forget prior results, and branch-and-rejoin workflows all rely on durable checkpoints. Thread ids are the handle operators and UIs use to resume the correct run.
Mistakes that keep appearing
- Calling
setup()on every request. - Using SQLite under multiple Gunicorn/Uvicorn workers.
- Oversized
thread_idvalues. - Putting irreversible side effects before
interrupt(). - Forgetting to pass the same
thread_idon resume. - Assuming in-memory demos equal production durability.
Practices that travel well
Run schema setup as a migration. Prefer pools. Use async savers in async apps. Keep thread ids short and stable. Document which environments use which backend. Add smoke tests that crash and resume. Monitor checkpoint table growth and retention.
Summary
Production LangGraph persistence is mostly “same graph API, different durable store.” Postgres is the default enterprise choice; SQLite and Redis fill niches; custom savers cover mandated infrastructure. Human-in-the-loop and crash recovery are not optional features—they are why checkpointers exist. Wire the backend early so interrupt and resume are real before the agent meets users.
Operational runbook for checkpoint backends
Treat checkpoint storage like application primary data. Backup schedules, restore drills, and retention policies belong in the same conversations as customer records. A chat product that cannot restore threads after a regional failover has not actually shipped memory—it shipped hope.
Capacity planning should track checkpoints per active thread per day, average blob size for message-heavy states, and growth under peak concurrency. Large tool payloads stuffed into state will dominate Postgres size long before row counts look scary. Prefer storing bulky artifacts in object storage and keeping pointers in state.
Access control matters: the database user for the checkpointer should not be the same role that migrates unrelated schemas with DDL superpowers. Rotate credentials, prefer IAM auth where cloud vendors allow it, and audit who can SELECT conversation state. Thread ids often map to users; treat dumps as PII.
When migrating from InMemorySaver to Postgres in a live system, dual-write is rarely necessary if you accept that in-flight RAM threads reset once. Document a maintenance window, deploy the new checkpointer, and monitor error rates on resume. For blue/green cutovers, ensure both colors point at the same database or you will split brains across thread histories.
Testing tips: spin Postgres in CI (or a disposable container), run setup() once, execute the five example shapes as integration tests, and assert that a second process can resume a thread created by the first. That single cross-process test catches more production bugs than any in-memory unit test.
Finally, teach on-call the difference between “graph failed” and “checkpointer unreachable.” The symptoms rhyme—invokes error—but the fixes diverge. Dashboards that separate application exceptions from database timeouts save hours during incidents.
Thread identity and product UX
Thread ids are not only database keys; they are product objects. Map them to user sessions, support tickets, or document ids deliberately. Expose “continue conversation” in the UI by reusing the id. Expose “start fresh” by minting a new one. Mixing those behaviors in one id creates haunted chats where old tool results reappear.
For multi-device users, stickiness of thread ids across clients requires auth-aware lookups. Never accept a client-supplied thread id without verifying ownership. Checkpointers store whatever you give them; they do not enforce tenancy.
Schema evolution for checkpointed state
State TypedDicts evolve. Adding optional fields is easy; renaming or removing fields breaks old checkpoints. Prefer additive changes, readers that default missing keys, and explicit migrations for destructive shifts. Store a schema_version in state and branch node logic on it when needed.
Large message lists deserve trimming policies: summarize older turns into a field, keep recent raw messages, and test resume after trimming. Unbounded history is the most common reason Postgres checkpoint tables explode.
Interrupt UX patterns
Approval UIs should show the interrupt payload, the relevant state slice, and deadlines. Store approver identity in state on resume. If approvals expire, run a watchdog that cancels or escalates rather than leaving threads paused forever. Persistence enables long waits; product policy must bound them.
Disaster recovery drills
Quarterly, restore a backup into staging and resume a known thread id. Confirm messages, interrupt status, and tool residues match. Document RPO/RTO for the checkpoint database alongside the primary app database. Agents that remember users inherit the same continuity expectations as email systems.
Performance notes
Connection pools should be sized for concurrent graph workers, not just web handlers. Watch for N+1 patterns where every node writes a checkpoint unnecessarily—tune checkpointer usage according to LangGraph version defaults. Compress or externalize bulky binary tool outputs.
Security footnotes
Encrypt disks, use TLS to Postgres, restrict network paths from workers to the store, and scrub secrets from state before checkpoint if tools ever see raw keys. Consider field-level redaction middleware. Compliance reviews will ask; prepare answers with diagrams of data flow from user → graph → checkpointer → disk.
These operational layers turn the code examples into a system you can sleep on. The APIs are small; the surrounding discipline is the real production feature.
Coordinating checkpointers with horizontal autoscaling
When Kubernetes scales graph workers from two pods to twenty, every pod must speak to the same durable checkpointer. Sticky sessions are unnecessary if thread ids travel with each request. Warm pools should not assume local SQLite files. Health checks should verify database connectivity, not only process liveness. During deploys, draining workers should finish or checkpoint mid-run so interrupts remain resumable. Chaos-test by killing pods during interrupt() waits and confirming resume on a different pod. That single experiment validates the architecture more than a dozen unit tests.
Document backpressure: if the checkpoint store slows, should workers shed load or queue? Prefer explicit failure over silent in-memory fallbacks that disagree across pods. Operators must never “temporarily” switch to InMemorySaver in production to “fix latency”—that switch deletes the product promise.
Checklist before calling persistence “done”
Confirm setup() runs as migration; pools configured; async vs sync matched to the web framework; thread id ownership enforced; interrupt resume tested across processes; backups restored successfully once; retention job documented; PII handling reviewed; dashboards distinguish DB errors from graph errors; runbooks link thread ids to user support tooling. When each box is checked, PostgresSaver (or your chosen backend) is no longer a code snippet—it is part of the product’s memory guarantee. Until then, treat demos as demos and keep feature flags off for long-lived HITL flows.
A short story of a resume that failed
An on-call engineer once saw approvals stuck for hours. The graph was fine; the checkpointer password rotated and workers cached old credentials. New interrupts failed while old threads still read. The fix was credential refresh plus a playbook line: verify checkpointer auth before restarting graph pods. Persist that lesson beside your PostgresSaver snippets so the next rotation does not repeat the outage. Durable memory includes operational memory—the human kind written in runbooks.
That incident also clarified monitoring: alert on checkpointer authentication failures as a distinct page, not a generic 500 bucket. When memory is the product, the database path is a tier-zero dependency. With that discipline, swapping InMemorySaver for PostgresSaver stops being a scary migration and becomes a routine promotion step on the path from notebook to service.