LangGraph PostgresSaver Connection & Migration Errors
PostgresSaver is the right checkpointer for multi-instance production, but its first-hour failure modes are ugly: schema not created, connection pool exhausted, JSONB rejected, and a driver that doesn't match your app's sync/async story. Here's how to get it stable.
By Sana K. · Last updated Aug 14, 2026 · LangGraph · Page #128
PostgresSaver needs three things done right: (1) run setup() once against every fresh database, (2) size the connection pool for your worker count, and (3) pick the sync or async variant to match your app. Use AsyncPostgresSaver.from_conn_string as an async context manager in FastAPI. Never share one psycopg connection across threads.Real error messages you'll see
psycopg.errors.UndefinedTable: relation "checkpoints" does not exist
at PostgresSaver.get_tuple() / put()
# The Postgres database exists but PostgresSaver.setup() was never run against it.
# Fix: run checkpointer.setup() once at deploy time, or in a startup task on first boot.
psycopg_pool.PoolTimeout: couldn't get a connection after 30.0 sec (pool size: 10, waiting: 12)
at AsyncPostgresSaver during Pregel.astream()
# Your app has more concurrent requests than the pool allows. Either raise pool max_size or reduce per-request checkpoint frequency.
RuntimeError: PostgresSaver is a synchronous checkpointer; use AsyncPostgresSaver inside an async event loop.
at Pregel.astream()
# You imported the sync variant but the graph runs from async code. Switch to AsyncPostgresSaver.
PostgresSaver connection setup patterns
| Deployment | Saver | Pattern |
|---|---|---|
| One-off script | PostgresSaver | with PostgresSaver.from_conn_string(dsn) as saver |
| Long-lived sync app | PostgresSaver + ConnectionPool | Pool at startup, saver from pool per graph |
| FastAPI (async) | AsyncPostgresSaver + AsyncConnectionPool | Pool in lifespan, one graph shared across requests |
| Serverless (Vercel, Modal) | AsyncPostgresSaver + PgBouncer | External pooler; keep local pool small |
| Multi-region | Same, with read replicas | Write to primary, LangGraph state on primary only |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 26%Missing
setup()call. Schema tables never created. Every operation raisesUndefinedTable. Run once per deploy against every fresh database. - 19%Connection pool too small. Under load, requests block waiting for a connection and time out. Default pool sizes are conservative; raise
max_sizeto match worker count. - 15%Sync/async mismatch. Sync
PostgresSaverused inside async handler, or vice versa. LangGraph raises immediately. - 12%Wrong driver version.
psycopg2installed instead ofpsycopg(v3). LangGraph's Postgres saver requires psycopg 3. - 9%PgBouncer in transaction mode without prepared-statement cache disabled. LangGraph uses prepared statements internally; PgBouncer transaction pooling breaks them silently.
- 7%DATABASE_URL uses
postgres://instead ofpostgresql://. Some libraries normalize, psycopg does not. Connection fails at import. - 7%Connection reused across event loops. Created a connection in one asyncio loop and used it in another (common in test harnesses). Raises obscure errors from psycopg's async adapter.
- 5%JSONB serialization fails on custom objects. State contains an object without JSON serialization (a raw
datetime, a Pydantic model withoutmodel_config, a numpy array). Add a serializer or convert before returning.
How to fix it
Run setup() once against every fresh database
Fixes UndefinedTable: relation "checkpoints" does not exist.
PostgresSaver.setup() creates the tables LangGraph uses (checkpoints, checkpoint_writes, checkpoint_blobs). Run it once per database — at deploy time, in a migration script, or as a boot-time task guarded by a flag. It's idempotent: safe to run repeatedly, but you don't want to on every request.
# ✅ One-off setup script — run before first deploy of a new database
from langgraph.checkpoint.postgres import PostgresSaver
DATABASE_URL = "postgresql://user:pass@host:5432/langgraph"
with PostgresSaver.from_conn_string(DATABASE_URL) as checkpointer:
checkpointer.setup()
print("Checkpoint tables created:")
print(" - checkpoints")
print(" - checkpoint_writes")
print(" - checkpoint_blobs")
# ✅ Async equivalent — for setup from an async app
import asyncio
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
async def init():
async with AsyncPostgresSaver.from_conn_string(DATABASE_URL) as saver:
await saver.setup()
asyncio.run(init())
# ✅ Migration-style setup — run once per deploy, no-op if already set up
# migrations/001_langgraph_checkpoints.py
from langgraph.checkpoint.postgres import PostgresSaver
import os, sys
def run_migration():
dsn = os.environ["DATABASE_URL"]
if not dsn.startswith("postgresql://"):
# LangGraph/psycopg 3 requires the postgresql:// scheme
dsn = dsn.replace("postgres://", "postgresql://", 1)
with PostgresSaver.from_conn_string(dsn) as saver:
saver.setup()
print("LangGraph checkpoint schema is up to date.")
if __name__ == "__main__":
try:
run_migration()
except Exception as e:
print(f"Migration failed: {e}", file=sys.stderr)
sys.exit(1)
# ❌ ANTI-PATTERN — calling setup() inside every request
# Slow, causes lock contention, floods the query log
# @app.post("/chat")
# def chat():
# with PostgresSaver.from_conn_string(DSN) as saver:
# saver.setup() # <-- don't do this per request
# # ...
saver.setup() to your CI deploy pipeline so every new database (dev, staging, prod) is initialized identically. Some teams gate it behind a boolean env var: if os.environ.get("RUN_LANGGRAPH_MIGRATION"): saver.setup().Size the connection pool for your worker count
Fixes PoolTimeout: couldn't get a connection.
The default pool sizes for psycopg_pool are small (conservative starting values). Every concurrent LangGraph invocation needs at least one connection; long-running graphs may hold connections for seconds. Match max_size to your worker count × concurrency per worker, and set a reasonable timeout so failures are fast.
# ✅ Async pool for FastAPI — sized for the number of concurrent requests you expect
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from fastapi import FastAPI
from contextlib import asynccontextmanager
DATABASE_URL = "postgresql://user:pass@host:5432/langgraph"
@asynccontextmanager
async def lifespan(app: FastAPI):
# Sizing rule of thumb:
# max_size = num_workers * expected_concurrent_requests_per_worker
# min_size = a warm baseline you always want ready
pool = AsyncConnectionPool(
DATABASE_URL,
min_size=5,
max_size=40, # tune to your load
timeout=10, # fail fast if no conn in 10s
max_lifetime=3600, # recycle conns hourly
max_idle=300, # release idle conns after 5 min
kwargs={"autocommit": True, "prepare_threshold": None},
)
await pool.open()
checkpointer = AsyncPostgresSaver(pool)
await checkpointer.setup() # idempotent
app.state.pool = pool
app.state.graph = build_graph(checkpointer)
yield
await pool.close()
api = FastAPI(lifespan=lifespan)
@api.post("/chat/{thread_id}")
async def chat(thread_id: str, message: str):
config = {"configurable": {"thread_id": thread_id}}
return await api.state.graph.ainvoke(
{"messages": [HumanMessage(content=message)]},
config,
)
# ✅ Monitor pool health — expose a /metrics endpoint
@api.get("/pool-stats")
async def pool_stats():
p = api.state.pool
return {
"size": p.get_stats().get("pool_size"),
"available": p.get_stats().get("pool_available"),
"waiting": p.get_stats().get("requests_waiting"),
}
# Sync equivalent — for non-async apps
from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver
pool = ConnectionPool(DATABASE_URL, min_size=5, max_size=40, timeout=10)
checkpointer = PostgresSaver(pool)
checkpointer.setup()
max_size=2-5) and use PgBouncer or Supavisor in front of Postgres. Serverless instances open and close many pools; the external pooler multiplexes them onto a bounded set of Postgres connections.Configure PgBouncer for LangGraph — disable prepared statement cache
Fixes silent failures when routing through PgBouncer transaction mode.
PgBouncer in transaction mode pools connections at the transaction boundary, which breaks psycopg's prepared-statement cache. LangGraph uses prepared statements internally. Fix by passing prepare_threshold=None to psycopg's connection kwargs (disables preparing), and set PgBouncer's server_reset_query conservatively.
# ✅ Connect to Postgres through PgBouncer in transaction mode (Supabase, RDS Proxy)
from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
PGBOUNCER_URL = "postgresql://user:pass@pgbouncer:6543/langgraph"
pool = AsyncConnectionPool(
PGBOUNCER_URL,
min_size=2,
max_size=10,
timeout=10,
kwargs={
"autocommit": True,
# THE fix — disables psycopg prepared statement cache
# PgBouncer transaction mode cannot preserve prepared statements
# across connection reuse.
"prepare_threshold": None,
},
)
# ✅ Direct-to-Postgres (no PgBouncer) — leave prepared statements ON for perf
DIRECT_URL = "postgresql://user:pass@postgres:5432/langgraph"
direct_pool = AsyncConnectionPool(
DIRECT_URL,
min_size=5,
max_size=40,
kwargs={"autocommit": True}, # no prepare_threshold override
)
# ✅ Two-URL pattern (Supabase-style):
# - Direct URL for migrations (needs prepared statements)
# - Pooler URL for runtime (needs prepare_threshold=None)
import os
if os.environ.get("PHASE") == "migrate":
# Migration uses direct URL
with PostgresSaver.from_conn_string(os.environ["DIRECT_URL"]) as saver:
saver.setup()
else:
# Runtime uses pooler URL
pool = AsyncConnectionPool(
os.environ["POOLER_URL"],
max_size=20,
kwargs={"prepare_threshold": None},
)
checkpointer = AsyncPostgresSaver(pool)
# ❌ WRONG — using PgBouncer transaction mode without disabling prepare cache
# Every few requests silently returns stale data or errors with
# "prepared statement 'ps_1' does not exist"
# pool = AsyncConnectionPool(PGBOUNCER_URL, kwargs={"autocommit": True}) # missing prepare_threshold=None
DIRECT_URL, application code uses POOLER_URL with prepare_threshold=None.Prevention checklist
- Run
PostgresSaver.setup()once per fresh database — in a migration script or a guarded boot task. - Size the pool for your worker count:
max_size >= workers × concurrent_requests_per_worker. - Use
psycopgversion 3, notpsycopg2. LangGraph's Postgres saver requires v3. - Match saver to app:
PostgresSaverfor sync,AsyncPostgresSaverfor async. No mixing. - When routing through PgBouncer in transaction mode, set
prepare_threshold=Nonein psycopg kwargs. - Use
postgresql://URL scheme, notpostgres://. Psycopg 3 rejects the latter. - Add a
/pool-statsendpoint or log the pool state at intervals — pool exhaustion is often the first sign of a broader load problem.
Frequently asked questions
The moment you deploy more than one worker process, or need HA. SQLite is single-writer — a second gunicorn worker will block or fail. Postgres handles concurrent writers cleanly. Also switch when checkpoint size exceeds a few GB; Postgres query planning and indexing handle large tables better than SQLite.
Yes, but keep the schemas separate. LangGraph creates checkpoints, checkpoint_writes, and checkpoint_blobs tables. Use a dedicated schema: SET search_path TO langgraph, public. That way LangGraph's tables can't collide with yours, and you can back up / restore the checkpoint schema independently.
Postgres autovacuum handles most cases. If your app churns through many short-lived threads (create → chat → discard), you may want to add a periodic cleanup: DELETE FROM checkpoints WHERE thread_id IN (SELECT thread_id FROM ... WHERE created_at < NOW() - INTERVAL '30 days'). LangGraph doesn't currently ship an auto-eviction feature; you own retention.
Last write wins. LangGraph doesn't use optimistic locking on checkpoint writes as of current versions. If your app needs strict serialization per thread, use a Redis-based lock keyed on thread_id, or queue per-thread work. Most chat UIs don't hit this because a single user opens one WebSocket / one request at a time; agents with parallel branches within one thread are safe because that's within one invoke.
Absolutely — that's a very common setup. Install pgvector extension, run its CREATE EXTENSION, then run PostgresSaver.setup(). They don't conflict. The main thing to watch is connection pool sizing: both LangGraph and your retriever will hold connections, so bump max_size.