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 TABLEandCREATE INDEXin 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, andpsycopg[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 withautocommit=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. Setautocommit=Trueon any connection you create yourself. - wrong schema or database: you ran setup against
postgresand the app connects toagent, or your role has asearch_paththat 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
- self-hosted LangGraph checkpointing vs LangGraph Cloud for long tool calls: whether to keep running this yourself once the tables exist
- why a LangGraph run can vanish with no error if it crashes before its first checkpoint: the next failure after this one
- best tools for tracing and debugging AI agents: seeing what your agent did, beyond what it stored
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.