← all articles

Fix relation checkpoints does not exist in LangGraph Postgres setup

You swap MemorySaver for the Postgres checkpointer, point it at a fresh database, run your graph, and get this:

psycopg.errors.UndefinedTable: relation "checkpoints" does not exist

This is the question people keep asking in langchain-ai/langgraph issue 2062, and the answer is short. The checkpointer does not create its own tables when you construct it. Nothing runs CREATE TABLE until you call setup() once. Skip that call and the first invoke or stream tries to read from a table that was never made.

This is for anyone running a LangGraph agent against their own Postgres, whether that is a local Docker container, a managed instance, or a Supabase project. By the end you will have the tables created, a migration step you run once per environment, and a check that proves your state survives a process restart. If you are still deciding whether to host checkpointing yourself at all, I compared the two routes in self-hosted LangGraph checkpointing vs LangGraph Cloud for long tool calls.

What you need

  • python 3.10 or newer, with a virtualenv you can throw away
  • a reachable Postgres database. a local container is fine, and a managed free tier works too. as of October 2026 I run this against Postgres 16, but the migrations use plain DDL and I have no reason to think older supported versions behave differently
  • a connection string in the form postgresql://user:password@host:5432/dbname
  • a database role that can CREATE TABLE and CREATE INDEX in the target schema. the app role at runtime does not need DDL rights once setup has run, but the role that runs setup does
  • langgraph, langgraph-checkpoint-postgres, and psycopg[binary,pool] installed from PyPI. the checkpointer package is listed on PyPI as langgraph-checkpoint-postgres
  • about 20 minutes, zero cost with a local container

If you run through a connection pooler like PgBouncer in transaction mode, read the pitfalls section before you start. It changes how you build the connection.

Step by step

1. Reproduce the error on a clean database

Start a throwaway Postgres so you know the failure is the missing tables and nothing else.

docker run --name lg-pg -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=agent -p 5432:5432 -d postgres:16
python -m venv .venv && source .venv/bin/activate
pip install langgraph langgraph-checkpoint-postgres "psycopg[binary,pool]"

Now the smallest graph that will trip the error. Save this as repro.py:

from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://postgres:dev@localhost:5432/agent"

class State(TypedDict):
    count: int

def bump(state: State) -> State:
    return {"count": state["count"] + 1}

builder = StateGraph(State)
builder.add_node("bump", bump)
builder.add_edge(START, "bump")
builder.add_edge("bump", END)

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)
    cfg = {"configurable": {"thread_id": "t1"}}
    print(graph.invoke({"count": 0}, cfg))

Expected output: a traceback ending in UndefinedTable: relation "checkpoints" does not exist.

If it breaks differently: a connection refused or auth error means the container is not up or the password is wrong. Fix that first, because this article only covers the missing-table case.

2. Confirm the tables really are missing

docker exec -it lg-pg psql -U postgres -d agent -c "\dt"

Expected output: Did not find any relations. on a fresh database.

If it breaks: if you do see tables, you may be connected to a different database or schema than your app is. Run \conninfo and compare it against DB_URI.

3. Add the one-time setup() call

The fix is one line, placed after you build the checkpointer and before you compile or invoke the graph:

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup()   # creates the tables and runs migrations
    graph = builder.compile(checkpointer=checkpointer)
    cfg = {"configurable": {"thread_id": "t1"}}
    print(graph.invoke({"count": 0}, cfg))

Expected output: {'count': 1}.

If it breaks: if setup() raises a permission error, your role cannot create tables in the schema. Grant it, or run setup with an owner role and let the app use a restricted one (step 6).

4. Look at what setup() created

docker exec -it lg-pg psql -U postgres -d agent -c "\dt"

Expected output: four tables. checkpoints, checkpoint_blobs, checkpoint_writes, and checkpoint_migrations. As of October 2026 that is what I see with the current package, but table names are an implementation detail of the library, so check the output rather than trusting my list when you upgrade.

The last one is the interesting one. checkpoint_migrations stores the version number of each migration already applied. When you call setup() again, it reads that table and only runs migrations it has not seen. That is why calling it on every boot is safe, and why it is also the “migration step” people mean when they say you run it once.

If it breaks: an empty checkpoint_migrations table with missing others usually means a previous setup crashed halfway. See the autocommit pitfall below.

5. Prove state survives a restart

The whole point of Postgres over MemorySaver is that a new process picks up where the old one stopped. Write resume.py:

from langgraph.checkpoint.postgres import PostgresSaver
from repro import builder, DB_URI   # reuse the graph definition

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)
    cfg = {"configurable": {"thread_id": "t1"}}
    snap = graph.get_state(cfg)
    print(snap.values)

Wait, importing repro would rerun it. Move the graph into its own module or guard the invoke in repro.py with if __name__ == "__main__":. Then run python resume.py in a fresh shell.

Expected output: {'count': 1}, read from Postgres by a process that never saw the first run.

If it breaks: an empty dict means the thread_id differs from the one you wrote. State is keyed by thread, so a typo gives you a clean slate with no error.

6. Move setup() out of the request path

Calling setup() inside every worker at boot works for one process. With ten workers starting together, you get ten processes racing to create the same tables and indexes. I treat it as a deploy step instead, a tiny script that runs before the app starts:

# migrate_checkpointer.py
import os
from langgraph.checkpoint.postgres import PostgresSaver

with PostgresSaver.from_conn_string(os.environ["DB_URI_OWNER"]) as cp:
    cp.setup()
print("checkpointer schema ready")

Run it from your release pipeline or as an init container, using an owner role in DB_URI_OWNER. The app itself connects with a lower-privilege role that only needs read and write on those four tables.

Expected output: checkpointer schema ready, and no change on the second run.

If it breaks: if two deploys run it at once and one fails on a duplicate object, just re-run it. The migration table makes the second pass a no-op.

7. Build your own connection the right way (if you pool)

If you pass your own psycopg connection or pool instead of using from_conn_string, the library expects specific settings. The langgraph-checkpoint-postgres documentation notes that a connection you provide needs autocommit=True and row_factory=dict_row. Here is the pool version:

from psycopg_pool import ConnectionPool
from psycopg.rows import dict_row
from langgraph.checkpoint.postgres import PostgresSaver

pool = ConnectionPool(
    conninfo=DB_URI,
    max_size=10,
    kwargs={"autocommit": True, "prepare_threshold": 0, "row_factory": dict_row},
)
checkpointer = PostgresSaver(pool)
checkpointer.setup()

The psycopg connection options are covered in the psycopg 3 documentation.

Expected output: no error, and the same four tables.

If it breaks: a TypeError about tuple indices on the first read means row_factory=dict_row is missing.

8. Handle async the same way

If your agent is async, the class is AsyncPostgresSaver and setup() is awaited:

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)
    result = await graph.ainvoke({"count": 0}, cfg)

Forgetting the await is its own trap. You get a “coroutine was never awaited” warning, which people read past, and then the same missing-relation error shows up on the first run.

Expected output: {'count': 1} for a fresh thread id.

If it breaks: if you see the warning, add the await. The tables were never created.

Common pitfalls

  • calling setup() but not seeing tables: this is nearly always autocommit. When you hand the saver a connection with autocommit=False, the DDL can sit in an open transaction that never commits, and one of the migrations builds an index in a way that cannot run inside a transaction block. Set autocommit=True on any connection you create yourself.
  • wrong schema or database: you ran setup against postgres and the app connects to agent, or your role has a search_path that puts the tables in a different schema than the one the app reads. Postgres documents how search_path resolves unqualified names, and it is worth five minutes if setup “worked” but the app still cannot see the tables.
  • PgBouncer in transaction mode: server-side prepared statements do not survive being handed to a different backend connection. Setting prepare_threshold: 0, as in step 7, turns them off on the client side. Without it you get intermittent errors that look unrelated to missing tables.
  • a restricted app role running setup: the first deploy fails on permissions, someone grants superuser to get past it, and now the app runs with far more rights than it needs. Keep the owner role for the migration script only.
  • trusting that a crash leaves a trace: if a run dies before its first checkpoint is written, there is nothing in the database to resume from, and no error either. I wrote up that case in why a LangGraph run can vanish with no error if it crashes before its first checkpoint. If your tables exist but a thread is empty, read that next.

Scaling this

At 10x, meaning a handful of agents and a few hundred threads, nothing here needs attention. One Postgres instance, setup() in a release step, a pool of 5 to 10 connections. Checkpoints are written after each graph step, so the write volume is roughly steps per run times runs.

At 100x, the checkpoints and checkpoint_writes tables grow without bound because every step of every thread is kept. Decide a retention rule before you need one. I delete threads that have been finished for a set number of days with a scheduled job, and I keep the rule in version control so it is not tribal knowledge. Size your pool against worker count, not request count, since each running graph holds a connection only while it reads or writes a checkpoint. This is also where a pooler starts earning its place, which brings back the prepare_threshold setting.

At 1000x, the questions change from “are the tables there” to operations. You want a read replica or at least a separate role for reporting so analysts do not scan the live checkpoint tables. You want index and bloat monitoring, because a table with constant inserts and deletes needs vacuum to keep up. And you want traces, not only stored state, so you can tell why a run took the path it did. I rounded up what I have used in best tools for tracing and debugging AI agents. If your agents also drive browsers, the cost side of long-running sessions is covered in cost engineering for computer-use agents.

Schema upgrades are the other 1000x concern. When you upgrade langgraph-checkpoint-postgres, new migrations may ship. Because setup() is idempotent and versioned, the safe pattern is the same one from step 6: run the migration script against the owner role first, then roll the app. Never let a fleet of workers discover a pending migration on their own.

Where to go next

You can find everything else in the blog index.

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-10-16.

free download
Why did my agent get blocked? A triage checklist

The checks we run, in order, when a browser or phone agent starts failing: network, fingerprint, behaviour, account. Leave your email and we will also tell you when we publish a new field note, a few times a month at most.

from the team behind this site
A Singapore mobile IP for browser agents

Singapore Mobile Proxy runs real mobile IPs on SingTel, StarHub and M1, with sticky sessions so one task keeps one IP. Singapore only: a fit for SEA or location-agnostic work, the wrong tool if you need a US IP.

see plans →
from the team behind this site
A real Android phone for phone-use agents

cloudf.one hosts real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP. For agents that need an actual device and a stable carrier identity.

get a phone →
read on
More from The Agent Ops Report

Blocks, sessions, retries, traces, cost per task and phone-use agents. Browse all articles →