I’m using open-source LangGraph with PostgresSaver and durability: "exit".
The checkpoints table looks fine for my use case since I’m seeing roughly one checkpoint per chat request. The issue is that checkpoint_blobs keeps growing much faster over time.
For people running LangGraph with Postgres in production, how do you usually manage this growth?
Do you periodically delete older blobs/checkpoints after some retention period, keep only the latest N checkpoints per thread, or use another cleanup strategy?
I still want checkpoint durability, but I don’t need unlimited historical checkpoint data for every conversation.
Would appreciate hearing how others handle checkpoint retention and database size in the open-source libraries.
Large channel values (messages, state fields) are stored in checkpoint_blobs, not in the checkpoints row itself. With durability: "exit" you get about one checkpoint per request, but each run can still create new blob versions for changed channels. See Checkpointers for how checkpoints and blobs relate.
What the docs recommend
Prune old history, keep current state. The Persistence guide calls this out under “Checkpoints growing unboundedly”: prune periodically or set a retention policy. Use checkpointer.prune(thread_ids, strategy="keep_latest"), which keeps the latest checkpoint per namespace and removes older checkpoints, blobs, and writes together. API: BaseCheckpointSaver.prune.
Delete finished conversations entirely. The Memory guide documents checkpointer.delete_thread(thread_id) under “Delete all checkpoints for a thread”. Use this for threads you no longer need, or run it on a schedule for threads older than your retention window.
Do not manually delete from checkpoint_blobs or checkpoint_writes. Use prune or delete_thread so cleanup stays consistent with LangGraph’s internal model (including time travel and resume).
If you run LangGraph Server / Agent Server, configure TTL in langgraph.json with strategy: "keep_latest". That keeps the thread and latest checkpoint per namespace and removes older data automatically. See Configure TTL.
If you use DeltaChannel, read the pruning section in Checkpointers. Naive keep_latest can break delta channel reconstruction if it drops ancestor writes the surviving checkpoint still depends on.
Typical self-hosted pattern
Keep durability for active threads. After a conversation completes (no pending interrupts), call prune(..., strategy="keep_latest"). For expired threads, call delete_thread. That limits DB growth without losing the ability to resume active conversations.
Building on the answer above, which handles retention: there is a second source of growth worth separating out, because prune and TTL don’t touch it.
checkpoint_blobs grows on two axes. prune(strategy="keep_latest") and delete_thread bound the first: how many checkpoints you keep. The second is how big each checkpoint is. The messages channel is written whole on every step that touches it, so turn n stores a blob with all n messages, and a thread’s blobs grow with the square of its length. Keep-latest removes the older versions, but the surviving blob still holds the whole conversation, which is why blobs outpace the checkpoints table even with durability="exit".
The fix for the second axis is to bound the message list at save time. You can do it in the graph with trim_messages or RemoveMessage, which also changes what the model sees. Or you can do it in the checkpointer, which leaves the graph alone and lets you keep the pruned turns: I maintain a small zero-dependency reducer that does this, and its ReducingSaver wraps PostgresSaver (or any BaseCheckpointSaver) so each checkpoint holds at most N messages, with a hook that receives the pruned turns once each for long-term memory:
from agentstate_reducer import MessageReducer, ReducerConfig
from agentstate_reducer.langgraph import ReducingSaver
saver = ReducingSaver(PostgresSaver.from_conn_string(url), MessageReducer(config=ReducerConfig(max_messages=20)))
graph = builder.compile(checkpointer=saver)
Together with keep_latest, per-thread storage is constant. Measured on PostgresSaver: after the window fills, the newest messages blob stops growing. One caveat: don’t use it on a DeltaChannel-backed channel, since it replaces the channel value. Docs: Any Checkpointer, Bounded (ReducingSaver) - Agent State Management