Running LangGraph's Postgres Checkpointer in Production (2026)

Connection pools, table bloat and the retention job nobody ships

Jai Garg

HoD, Software Development

16 min read  ·  Wed Jul 01 2026

A tape mechanism with a small glowing write head feeding a take-up reel wound many times larger than the machine

In development you have one thread, one user, and a checkpointer that has never been the slow part of anything. You swap InMemorySaver for PostgresSaver, call .setup(), and move on.

Why it stops being invisible is structural. A checkpointer is not called once per run. It is called on every super-step transition, synchronously, between the step that finished and the step about to start. A thirty-step agent hits the database on the order of thirty times, each hit inside the latency of a request already slow because it is waiting on models. Add a few hundred concurrent runs and the checkpointer becomes the throughput ceiling.

The other half: nothing in the open-source checkpointer ever deletes anything. No TTL, no retention policy, no sweeper. The tables grow until the nightly backup stops finishing. Both problems are far easier to fix before you have a large table.

What actually gets written, and when

.setup() creates four tables. checkpoint_migrations is bookkeeping. The other three do the work:

01CREATE TABLE checkpoints (
02 thread_id TEXT NOT NULL, checkpoint_ns TEXT NOT NULL DEFAULT '',
03 checkpoint_id TEXT NOT NULL, parent_checkpoint_id TEXT, type TEXT,
04 checkpoint JSONB NOT NULL, metadata JSONB NOT NULL DEFAULT '{}',
05 PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id));
06
07CREATE TABLE checkpoint_blobs (
08 thread_id TEXT NOT NULL, checkpoint_ns TEXT NOT NULL DEFAULT '',
09 channel TEXT NOT NULL, version TEXT NOT NULL, type TEXT NOT NULL, blob BYTEA,
10 PRIMARY KEY (thread_id, checkpoint_ns, channel, version));
11
12CREATE TABLE checkpoint_writes (
13 thread_id TEXT NOT NULL, checkpoint_ns TEXT NOT NULL DEFAULT '',
14 checkpoint_id TEXT NOT NULL, task_id TEXT NOT NULL, idx INTEGER NOT NULL,
15 channel TEXT NOT NULL, type TEXT, blob BYTEA, task_path TEXT NOT NULL DEFAULT '',
16 PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx));

Three consequences follow, and they are the whole article.

The `checkpoint` JSONB column does not contain your state. It holds channel_versions, versions_seen, the checkpoint id (a time-ordered UUID) and a timestamp. The values live in checkpoint_blobs, joined back on (thread_id, checkpoint_ns, channel, version).

A blob row holds the complete value of a channel at a version, not a delta. An unchanged channel costs nothing, but every time a channel does change the entire new value is serialised and written again. For a messages channel that only grows, step 20 writes a copy of twenty messages and step 21 writes twenty-one. Bytes per thread grow with the square of conversation length.

Rows per run, roughly: one checkpoints row per super-step plus one for the initial input; one checkpoint_blobs row per changed channel per super-step; one checkpoint_writes row per (task, index) pair — per channel each node wrote, including the pending writes that let a partially-failed super-step resume without re-running what succeeded. A thirty-step agent is not one row. It is dozens across three tables, and subgraphs multiply it again under their own checkpoint_ns.

One knob to know first: recent LangGraph exposes durability on invoke/stream with values "sync" (write each checkpoint before the next step), "async" (write concurrently with it) and "exit" (write only at the end of the run). Confirm the default in your version.

The failure modes

1. Connection pool exhaustion

Symptom. psycopg_pool.PoolTimeout: couldn't get a connection after 30.00 sec, or a latency cliff at one concurrency that does not move when you add replicas. Postgres logs FATAL: sorry, too many clients already.

Cause. Usually several at once. The psycopg pool defaults are small: min_size=4, and `max_size=None`, meaning the pool tops out at `min_size` — four connections, with a 30-second timeout before it raises. Separately, replicas × max_size must fit inside max_connections, commonly 100 by default.

Second, passing a single AsyncConnection to AsyncPostgresSaver instead of an AsyncConnectionPool. Every checkpoint operation then serialises on one connection, producing cannot send pipeline when not in pipeline mode or InvalidSqlStatementName when concurrent tasks collide.

Third — the one that matches "agents hold connections across long LLM calls" — a web framework opening a request-scoped transaction around the whole run. The checkpointer borrows a connection per operation and returns it immediately. Your ORM session does not; it holds one for the full wall-clock duration, model latency included.

Diagnosis. pool.get_stats() says whether the pool is the constraint: requests_waiting above zero, rising requests_wait_ms, pool_available near zero. Then group pg_stat_activity by stateidle in transaction is the ORM case, plain idle in bulk is pool sizing.

Fix. Pass the pool, not a connection, and set max_size explicitly. Never open a database transaction spanning an LLM call. Then check your installed version:

01python -c "import inspect, langgraph.checkpoint.postgres.aio as m; \
02print(inspect.getsource(m.AsyncPostgresSaver._cursor))"

Some releases acquire an instance-level asyncio.Lock around every cursor operation, including when you supplied a pool. That serialises all checkpoint I/O in the process regardless of pool size, and extra connections will not help.

2. No TTL, no retention, tables that grow forever

Symptom. Storage climbs in a straight line and never comes down. Backups get slower every week, and nobody can say what is safe to delete.

Cause. The open-source checkpointers have no retention mechanism. The ttl block with strategy, sweep_interval_minutes and default_ttl in langgraph.json is a LangGraph Platform feature; running PostgresSaver yourself, no sweeper exists. delete_thread(thread_id) exists in recent versions and does the right thing across all three tables, but something has to call it, and in most deployments nothing does.

Diagnosis. Compare pg_total_relation_size, pg_indexes_size and the size of reltoastrelid across the three tables. checkpoint_blobs is almost always largest by a wide margin, most of it in the TOAST relation.

Fix. A retention job, covered below. The naive version is wrong: there are no foreign keys between these tables. DELETE FROM checkpoints leaves the blob and write rows behind permanently, and the blobs are where the bytes are.

3. Channel values that grow without bound

Symptom. checkpoint_blobs dwarfs everything else. Per-step latency climbs over the course of a single conversation. Long threads are slow; short threads are fine.

Cause. Full-value-per-version writes applied to a messages channel with an append-only reducer. Serialised size at step *n* is proportional to *n*, so total bytes across a thread is proportional to *n²*.

Diagnosis. Find the responsible channel, then swap channel for thread_id to find the responsible conversations.

01SELECT channel, count(*) AS rows,
02 pg_size_pretty(sum(octet_length(blob))::bigint) AS bytes
03FROM checkpoint_blobs GROUP BY 1
04ORDER BY sum(octet_length(blob)) DESC NULLS LAST LIMIT 20;

Fix. The distinction that matters: trimming messages before the model call does not shrink your state. trim_messages used inside a node to build a prompt leaves the channel untouched, so the checkpointer keeps serialising the full history. To reduce it you must write the removal back into the channel — RemoveMessage(id=...), or the REMOVE_ALL_MESSAGES sentinel followed by the replacement list when you summarise. Summarisation middleware that only alters the prompt has the same problem; verify what lands in state, not what reaches the model.

Beyond messages, keep large objects out of state. Documents, file contents, embeddings, API payloads — write them to object storage and put the key in state. State is a resumption record, not a data bus.

4. Indexes that do not match your queries

Symptom. Point reads stay fast while cleanup jobs, history listings and metadata-filtered searches degrade as tables grow.

Cause. What ships is the primary key on each table plus a single-column thread_id index on each. get_state is fine — it hits the primary key, and the blob join is on the blob table's exact primary key. Not covered:

  • list() with a metadata filter compiles to metadata @> ... JSONB containment. There is no GIN index on metadata, so that predicate is evaluated as a filter.
  • Deleting by anything other than thread_id. There is no timestamp column on any of these tables, so "delete everything older than 30 days" has neither an index nor a column to use.
  • The shipped checkpoints_thread_id_idx duplicates the leading column of the primary key, so it buys little for lookups while costing a write on every insert. Do not add more indexes on that reasoning; each is paid for on your hottest write path.

Diagnosis. EXPLAIN (ANALYZE, BUFFERS) on what your application and cleanup job actually run, plus pg_stat_statements by total execution time. The answer is frequently list(), not put().

Fix. Add CREATE INDEX CONCURRENTLY checkpoints_metadata_gin ON checkpoints USING gin (metadata jsonb_path_ops) only if you genuinely filter by metadata.

Then treat .setup() carefully. It runs CREATE INDEX CONCURRENTLY, which cannot run inside a transaction block — hence the documentation's insistence on autocommit=True. A failed build leaves an INVALID index that costs writes while serving no reads, and several replicas calling .setup() at container boot is a good way to cause one. Run it once as a deploy step, not on every process start, and check with SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid.

5. Serialisation cost

Symptom. CPU burned in the application process, not the database. Gaps between steps that never appear in pg_stat_statements.

Cause. Serialisation and deserialisation happen on the same event loop that runs your graph. The default JsonPlusSerializer handles common types well; anything it does not recognise takes a slower path, and EncryptedSerializer adds crypto on top. Put a dataframe, a client handle or a deep dict of retrieved chunks in state and you pay for it on every super-step where that channel's version changes, for the life of the thread. Past roughly 2 kB (TOAST_TUPLE_THRESHOLD) Postgres also compresses the value and moves it out of line into the TOAST relation, which has its own storage, index and vacuuming.

Diagnosis. Look at the distribution rather than the mean — percentile_disc(0.95) WITHIN GROUP (ORDER BY octet_length(blob)) and max(octet_length(blob)) grouped by channel.

Fix. Keep state small and JSON-shaped, and split channels so a large, rarely-changing value does not share a version bump with a small, frequently-changing one.

6. Concurrent runs on the same thread_id

Symptom. Two requests arrive for the same conversation and one appears to lose. History forks. A user sees a reply that does not account for the message they just sent.

Cause. The open-source checkpointer has no locking and no optimistic concurrency on a thread. Two runs both read the latest checkpoint, both execute, both write new checkpoints with the same parent_checkpoint_id and different checkpoint_ids. Neither write fails. You have a fork, and "latest" is whichever the ordering picks. LangGraph Platform handles this above the checkpointer with multitask strategies (reject, enqueue, interrupt, rollback); self-hosting the library, you get none of that.

Diagnosis. Look for siblings sharing a parent. Some are legitimate — deliberate forks from time travel, or retries — but a steady rate tracking your traffic is not.

01SELECT thread_id, checkpoint_ns, parent_checkpoint_id, count(*) AS forks
02FROM checkpoints WHERE parent_checkpoint_id IS NOT NULL
03GROUP BY 1,2,3 HAVING count(*) > 1 ORDER BY forks DESC LIMIT 20;

Fix. Serialise per thread in your own application, above the graph. A Postgres advisory lock is the least new infrastructure: SELECT pg_try_advisory_xact_lock(hashtext($1)) keyed on thread_id. Or route by thread to a single consumer, or reject the second request at the API layer. Whichever you pick, pick it deliberately — the default is a silent race. This is one of the decisions we work through when designing production agent systems.

7. Autovacuum falling behind

Symptom. Table size far larger than live data. Plans that were fine last month choosing sequential scans. n_dead_tup that never returns near zero.

Cause. These are high-churn tables and autovacuum defaults are tuned for tables that are not. autovacuum_vacuum_scale_factor defaults to 0.2 with autovacuum_vacuum_threshold at 50, so a table waits until roughly 20% of its rows are dead — on a 50-million-row checkpoint_blobs, ten million dead tuples before anything happens, by which point the vacuum is long and expensive. autovacuum_max_workers defaults to 3 for the whole instance, and TOAST relations compete for the same workers as separate objects.

Diagnosis. n_dead_tup, last_autovacuum and autovacuum_count from pg_stat_user_tables. Dead tuples staying above 20% of live are the signal.

Fix. Override per table, including the TOAST relation where most blob bytes live:

01ALTER TABLE checkpoint_blobs SET (
02 autovacuum_vacuum_scale_factor = 0.02,
03 autovacuum_vacuum_threshold = 5000,
04 autovacuum_vacuum_cost_limit = 2000,
05 toast.autovacuum_vacuum_scale_factor = 0.02,
06 toast.autovacuum_vacuum_cost_limit = 2000);

Apply the same shape to the other two, and delete in bounded batches rather than one enormous statement so vacuum keeps pace. For bloat you already have, VACUUM FULL takes an ACCESS EXCLUSIVE lock; pg_repack does the same job online.

A retention strategy that works

Separate three things people conflate. Resumability needs the latest checkpoint of threads that might still continue. Time travel and replay need history. Audit needs an immutable record that need not live in your operational database at all. Most teams keep everything because they never made the distinction, then find their retention policy was accidentally "forever".

Own a threads table. The checkpoint tables have no timestamp column, so there is nothing to age out by. Your application knows which conversation was last touched; write that down in a table you control, with thread_id and last_activity_at. Everything keys off it.

Delete by thread_id, from all three tables. thread_id leads every primary key here, so those deletes are index-driven. delete_thread(thread_id) does exactly this. Batch it, a few thousand at a time with a pause, so autovacuum keeps up.

Trimming history within a live thread is harder, because checkpoint_blobs is keyed by (thread_id, checkpoint_ns, channel, version) with no checkpoint_id — you cannot delete "the blobs for checkpoint X". Delete the old checkpoints and checkpoint_writes rows first, then sweep blobs no surviving checkpoint references:

01DELETE FROM checkpoint_blobs b
02WHERE b.thread_id = $1
03 AND NOT EXISTS (
04 SELECT 1 FROM checkpoints c,
05 LATERAL jsonb_each_text(c.checkpoint -> 'channel_versions') AS cv(channel, version)
06 WHERE c.thread_id = b.thread_id AND c.checkpoint_ns = b.checkpoint_ns
07 AND cv.channel = b.channel AND cv.version = b.version);

On partitioning, be precise about what is possible. Hash partitioning by thread_id works, because thread_id is in every primary key and in the ON CONFLICT target the library uses; it spreads vacuum work but gives no cheap retention, since you cannot drop a hash partition to expire data. Range partitioning by time — where retention becomes DROP PARTITION with no dead tuples at all — needs a timestamp column, and in Postgres every unique constraint on a partitioned table must include the partition key. The primary key becomes (thread_id, checkpoint_ns, checkpoint_id, created_at), and the library's ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id) then fails with *there is no unique or exclusion constraint matching the ON CONFLICT specification*. Doable, but it means owning a modified schema and re-testing on every upgrade. For most teams a batched delete job is the correct trade.

Two cheaper levers first. ShallowPostgresSaver and its async variant keep only the latest checkpoint per thread, removing the growth problem structurally and removing time travel with it. And durability="exit" writes one checkpoint at the end of a run instead of one per super-step — reasonable for short, idempotent runs; wrong for anything long or expensive to redo.

If you need history for evaluation or incident review rather than resumption, archive it to object storage and delete the operational copy. That belongs with your tracing stack, which is where our LLMOps work usually starts.

Checkpointer backends compared

PostgresRedisSQLiteIn-memory
DurabilityFull ACID, WAL, PITR, replicasWhatever RDB/AOF is configured to give you; defaults can lose recent writesDurable on local disk, no replicationNone
ResumabilityFull history, time travel, pending-write recoveryFull history plus shallow savers; only as good as the persistence configFull, single nodeWithin the process only
RetentionNothing built in; you write and operate the cleanup jobNative TTL with optional refresh-on-read. Best of the fourNothing built inTrivially none
Operational burdenHighest: pool sizing, vacuum, index and bloat managementModerate: RediSearch and RedisJSON (bundled from Redis 8) plus memory sizingVery low, but no multi-replica storyNone
Best whenMulti-replica services, long-lived threads, you already run PostgresShort-lived threads where automatic expiry beats SQL accessSingle-process apps, local tools, CITests and examples

Redis's TTL is a real advantage and removes the operational work teams most often skip. Postgres wins mainly because it is already there, backed up, monitored and queryable with the SQL your team writes anyway.

Sizing: pool, volume, storage

Measure one representative thread before extrapolating. Run a realistic conversation end to end against a scratch database, then count rows and sum(octet_length(blob)) per table. The shapes:

  • checkpoints rows per run ≈ (super-steps + 1) × distinct namespaces
  • checkpoint_writes rows per run ≈ Σ over tasks of channels written per task
  • checkpoint_blobs rows per run ≈ Σ over super-steps of channels whose version changed
  • bytes per thread ≈ Σ over super-steps of the serialised size of the channels that changed — quadratic in turn count for an append-only history channel, so a thread twice as long is roughly four times the bytes

Storage is then bytes-per-thread × retained threads, plus indexes, plus headroom for dead tuples between vacuums. A high-churn table at 1.5× its logical size is normal.

Pool size follows Little's Law, not intuition. Connections needed ≈ (checkpoint operations per second) × (mean seconds per operation). Thirty operations of a few milliseconds each at twenty runs per second is a couple of connections of steady demand — which is why the honest answer is usually "larger than the default of four, but smaller than you assumed", and why trouble here is more often a held connection or an in-process lock than an undersized pool.

Then check the ceiling: replicas × max_size + migrations + admin must stay under max_connections. In Kubernetes this bites during rolling deploys, when old and new pods briefly both hold full pools — size for the overlap or put PgBouncer in front. Behind a transaction-pooling proxy, set prepare_threshold=None in your connection kwargs; prepared statements do not survive transaction pooling, and the resulting InvalidSqlStatementName errors look like a database fault rather than a configuration one. Tuning this layer is part of our Kubernetes consulting work.

FAQ

Do I need a checkpointer at all?

Not always. A single request-response graph with no interrupts, no resumption after failure and no memory across calls gains latency and storage for nothing. You need one the moment correctness depends on history: multi-turn conversation, interrupt() for approvals, long runs that must survive a pod restart, or anything you would want to replay.

Postgres or Redis?

Postgres if you already run it, if threads are long-lived, or if you want checkpoint data queryable with SQL under the same backup guarantees as the rest of your data. Redis if threads are short-lived and disposable — its native TTL removes the cleanup job Postgres users routinely fail to write. Redis needs RediSearch and RedisJSON, bundled from Redis 8 and otherwise via Redis Stack, and its durability is whatever your persistence configuration says it is.

How do I stop the tables growing?

Nothing in the library will do it for you. Keep a table of threads with a last-activity timestamp, run a batched job calling delete_thread() past your retention window, and confirm it removes rows from all three tables — deleting only from checkpoints orphans the blobs, which are the bytes. Then reduce what gets written: trim history back into state with RemoveMessage, keep large objects out of state, and consider a shallow saver or durability="exit".

Why is my agent slow at high concurrency?

In this order: whether max_size is still at its default of four; whether anything holds a database transaction open across an LLM call; whether your version's AsyncPostgresSaver._cursor takes an instance-level lock even when given a pool; and whether checkpoint_blobs has grown enough that serialising a long history every step is the real cost. The first three are configuration. The fourth is state design, and no database tuning fixes it.

Can I delete old checkpoints safely?

Yes, with two caveats. Deleting a thread's checkpoints ends any possibility of resuming or replaying it, so retention must exceed your longest legitimately-paused workflow — human-in-the-loop approvals are the trap, because a thread can sit idle for weeks and still be live. And deleting *part* of a thread's history requires sweeping unreferenced blobs afterwards, since blob rows are keyed by channel version rather than checkpoint id.

How do I test this before production?

Load-test the graph, not the database. Run realistic concurrency at realistic conversation lengths against a production-sized Postgres, with model calls stubbed at representative latency so you measure checkpoint behaviour rather than the provider. Watch pool.get_stats(), pg_stat_activity and table growth throughout, then run your retention job against a restored copy and check row counts across all three tables. Long-thread behaviour is what staging never reproduces, so include threads at ten times your median turn count.

If you are running LangGraph agents in production and any of this sounds like your week, we do a fixed-scope review of the persistence layer: what your graph writes per run, where the pool and vacuum limits sit, and a retention design that fits your resumability requirements. Details on the agentic AI engineering page.