LangGraph Postgres Checkpointer Connection & Migration Errors — Fix Guide (2026)
Checkpointing · PostgresSaver Severity: High

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.

TL;DRPostgresSaver 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 — no such relation
psycopg errors — no such relation
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.
Connection pool exhausted
Connection pool exhausted
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.
Sync PostgresSaver in async app
Sync PostgresSaver in async app
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

DeploymentSaverPattern
One-off scriptPostgresSaverwith PostgresSaver.from_conn_string(dsn) as saver
Long-lived sync appPostgresSaver + ConnectionPoolPool at startup, saver from pool per graph
FastAPI (async)AsyncPostgresSaver + AsyncConnectionPoolPool in lifespan, one graph shared across requests
Serverless (Vercel, Modal)AsyncPostgresSaver + PgBouncerExternal pooler; keep local pool small
Multi-regionSame, with read replicasWrite 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 raises UndefinedTable. 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_size to match worker count.
  • 15%
    Sync/async mismatch. Sync PostgresSaver used inside async handler, or vice versa. LangGraph raises immediately.
  • 12%
    Wrong driver version. psycopg2 installed instead of psycopg (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 of postgresql://. 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 without model_config, a numpy array). Add a serializer or convert before returning.

How to fix it

Fix #1

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.

postgres_setup.pypython
# ✅ 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
#         # ...
Note: Add 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().
Fix #2

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.

pool_sizing.pypython
# ✅ 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()
Note: If you're on serverless (Vercel, Modal), keep the local pool small (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.
Fix #3

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.

pgbouncer_setup.pypython
# ✅ 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
Note: If you're on Supabase, their docs recommend the pooler URL for runtime and the direct URL for schema changes. That maps exactly to LangGraph's pattern: migration script uses 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 psycopg version 3, not psycopg2. LangGraph's Postgres saver requires v3.
  • Match saver to app: PostgresSaver for sync, AsyncPostgresSaver for async. No mixing.
  • When routing through PgBouncer in transaction mode, set prepare_threshold=None in psycopg kwargs.
  • Use postgresql:// URL scheme, not postgres://. Psycopg 3 rejects the latter.
  • Add a /pool-stats endpoint or log the pool state at intervals — pool exhaustion is often the first sign of a broader load problem.

Frequently asked questions

When should I switch from SqliteSaver to PostgresSaver?

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.

Can I share Postgres between LangGraph and my application data?

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.

Do I need to run VACUUM on the checkpoint tables?

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.

What happens if two workers write to the same thread_id simultaneously?

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.

Can I use pgvector for retrieval AND PostgresSaver for checkpoints in the same DB?

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.

Related errors