teachyou.ai academy
← All posts
LangGraph

LangGraph Persistence Backends Compared: SQLite, Postgres, Redis

Pramod Dutta · Jun 18, 2026 · 16 min read

Your LangGraph agent works beautifully in the notebook. Then the process restarts, and every conversation, every partially completed workflow, every human-in-the-loop approval that was pending — gone. That is the moment most teams discover that the checkpointer they picked (or forgot to pick) is not a minor configuration detail. It is the memory of your entire agent system. LangGraph gives you three serious options for persistence backends — SQLite, Postgres, and Redis — and they behave very differently under concurrency, at scale, and in failure scenarios. In this guide we will look at how LangGraph checkpointing actually works under the hood, wire up real checkpointer code for each backend, and build a practical decision framework so you can pick the right one for your deployment instead of finding out the hard way in production.

What a LangGraph Checkpointer Actually Does

Before comparing backends, it helps to be precise about what is being persisted. In LangGraph, a checkpointer is any implementation of the BaseCheckpointSaver interface. Every time your graph finishes a super-step — one round of node executions — the checkpointer writes a snapshot of the entire graph state to storage. That snapshot is called a checkpoint, and it contains the channel values (your state dict), channel versions, metadata about which node wrote what, and any pending writes from nodes that completed before an interruption.

Checkpoints are grouped into threads. A thread is identified by the thread_id you pass in the run config, and it represents one logical conversation or workflow instance. When you invoke a graph with a thread_id that already has checkpoints, LangGraph loads the latest checkpoint, merges your new input into that state, and continues from where it left off.

This one mechanism powers almost every production-grade LangGraph feature:

  • Conversation memory — the messages channel survives across invocations, so your chatbot remembers earlier turns.
  • Human-in-the-loopinterrupt() pauses the graph mid-run; the checkpoint holds the paused state until a human resumes it, whether that is seconds or days later.
  • Fault tolerance — if a node crashes halfway through a run, you resume from the last successful checkpoint instead of replaying the whole graph.
  • Time travelget_state_history() walks backwards through checkpoints, letting you fork a conversation from any earlier point.

The interface is deliberately small: put stores a checkpoint, put_writes stores intermediate writes, get_tuple fetches a checkpoint, and list enumerates checkpoints for a thread. Because every backend implements the same contract, switching from SQLite to Postgres to Redis is usually a two-line change. The hard part is not the code — it is understanding which backend's storage characteristics match your workload. That is what the rest of this article is about.

The Baseline: InMemorySaver and Why It Is Not Persistence

The checkpointer you meet first in every tutorial is the in-memory one:

from langgraph.checkpoint.memory import InMemorySaver

checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "demo-1"}}
graph.invoke({"messages": [{"role": "user", "content": "hi"}]}, config)

InMemorySaver stores checkpoints in a Python dict. It is perfect for unit tests and quick experiments because it needs zero setup and is extremely fast. It is also completely ephemeral: restart the process and every thread vanishes. It offers no cross-process sharing, so two API workers behind a load balancer would each see a different, partial view of your threads.

Treat InMemorySaver the way you treat SQLite's :memory: mode in traditional apps — a development convenience, never a deployment target. The real question is which durable backend replaces it, and that is where SQLite, Postgres, and Redis come in.

SQLite: The Zero-Ops Starting Point

The SQLite checkpointer lives in its own package:

pip install langgraph-checkpoint-sqlite

The simplest way to use it is the context-manager form, which manages the connection lifecycle for you:

from langgraph.checkpoint.sqlite import SqliteSaver

with SqliteSaver.from_conn_string("checkpoints.db") as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)

    config = {"configurable": {"thread_id": "user-42"}}
    result = graph.invoke(
        {"messages": [{"role": "user", "content": "Remember my name is Ira."}]},
        config,
    )

For a long-lived application like a FastAPI server, you typically want a connection that lives for the duration of the process rather than a with block, so you construct the saver from an explicit connection:

import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver

conn = sqlite3.connect("checkpoints.db", check_same_thread=False)
checkpointer = SqliteSaver(conn)
graph = builder.compile(checkpointer=checkpointer)

Note the check_same_thread=False. SQLite connections are bound to a thread by default, and web servers dispatch requests across threads. The saver serializes access internally, but this flag is the difference between a working app and a cryptic threading error on your second concurrent request.

If your application is async — which most LLM apps should be, since they spend their lives waiting on model APIs — use the async variant backed by aiosqlite:

from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver

async with AsyncSqliteSaver.from_conn_string("checkpoints.db") as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)
    await graph.ainvoke(
        {"messages": [{"role": "user", "content": "hello"}]},
        {"configurable": {"thread_id": "user-42"}},
    )

Where SQLite shines. There is no server to run, no credentials to manage, no network hop. The database is a single file you can copy, back up, or delete. For a desktop app, a CLI agent, a single-user internal tool, or a prototype you want to survive restarts, SQLite is genuinely the right answer — not a compromise. Reads are fast because they are local, and durability is real: your threads survive process restarts and machine reboots.

Where SQLite hurts. SQLite allows exactly one writer at a time. Every checkpoint write takes a database-level lock, and concurrent writes from multiple threads queue up behind it. For a handful of simultaneous conversations this is invisible; for dozens of concurrent agent runs it becomes your bottleneck, and you will see database is locked errors under load if timeouts are misconfigured. The second structural problem is that a file on one machine cannot be shared by horizontally scaled replicas. The moment you run two instances of your service, SQLite is disqualified unless you introduce something like a network file system, which trades one problem for several worse ones.

The honest framing: SQLite is the best choice right up until you have either real concurrency or more than one machine, and then it is the wrong choice entirely.

Postgres: The Production Default

Postgres is what LangGraph's own managed platform uses under the hood, and the PostgresSaver implementation is the most battle-hardened of the three. Install the checkpoint package along with a modern Postgres driver:

pip install langgraph-checkpoint-postgres "psycopg[binary,pool]"

The first difference from SQLite is that Postgres checkpointers require an explicit setup() call to create their tables and run migrations:

from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://postgres:postgres@localhost:5432/agents"

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup()  # run once per database, before first use
    graph = builder.compile(checkpointer=checkpointer)

    config = {"configurable": {"thread_id": "user-42"}}
    graph.invoke(
        {"messages": [{"role": "user", "content": "hi"}]},
        config,
    )

Call setup() once when you provision the database — in a migration script or an app startup hook — not on every request. It creates the checkpoint tables (checkpoints, checkpoint_writes, checkpoint_blobs, and a migrations table) and is safe to call again; it no-ops when the schema is current.

For a real service you do not want one connection; you want a pool. PostgresSaver accepts a psycopg_pool.ConnectionPool directly:

from psycopg_pool import ConnectionPool
from langgraph.checkpoint.postgres import PostgresSaver

pool = ConnectionPool(
    conninfo=DB_URI,
    max_size=20,
    kwargs={"autocommit": True, "prepare_threshold": 0},
)
checkpointer = PostgresSaver(pool)
checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)

The autocommit=True kwarg matters: the saver manages its own transaction boundaries, and running it inside an implicit transaction block can cause setup() and writes to behave unexpectedly. The async version follows the same shape with AsyncPostgresSaver and AsyncConnectionPool:

from psycopg_pool import AsyncConnectionPool
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

async def build_graph():
    pool = AsyncConnectionPool(
        conninfo=DB_URI,
        max_size=20,
        kwargs={"autocommit": True, "prepare_threshold": 0},
    )
    await pool.open()
    checkpointer = AsyncPostgresSaver(pool)
    await checkpointer.setup()
    return builder.compile(checkpointer=checkpointer)

Where Postgres shines. Everything that made SQLite fragile is solved. Postgres handles many concurrent writers with row-level locking, so parallel agent runs on different threads do not contend with each other. Any number of app replicas can share one database, which makes horizontal scaling trivial. You inherit the entire Postgres operational ecosystem: point-in-time recovery, streaming replication, automated backups, monitoring, connection poolers like PgBouncer, and managed offerings from every cloud vendor — including serverless options like Neon where the free tier comfortably covers a small production agent. Postgres is also the backend for LangGraph's PostgresStore, the cross-thread long-term memory layer, so you can keep checkpoints and long-term memories in one database with one backup story.

Where Postgres costs you. It is a server you must run, secure, and maintain — or pay someone to. Every checkpoint read and write is a network round trip, so per-operation latency is higher than SQLite's local file access, though in an LLM application this is noise compared to model latency. Checkpoint tables also grow without bound by default: every super-step of every thread adds rows, and LangGraph does not delete old checkpoints for you. Plan a retention job that prunes threads older than your business needs, or the table that stores serialized state blobs will quietly become your largest relation.

If you are unsure which backend to pick and you are running any kind of multi-user service, pick Postgres. It is the default for a reason.

Redis: Speed and TTLs for High-Throughput Agents

The Redis checkpointer is the newest of the three, maintained in the langgraph-checkpoint-redis package:

pip install langgraph-checkpoint-redis

One important prerequisite: the Redis saver relies on RedisJSON and RediSearch capabilities, so you need Redis 8, Redis Stack, or a managed Redis with those modules enabled — a bare Redis 6 with nothing but strings and hashes will not work. Like Postgres, it needs a one-time setup() to create its search indices:

from langgraph.checkpoint.redis import RedisSaver

REDIS_URI = "redis://localhost:6379"

with RedisSaver.from_conn_string(REDIS_URI) as checkpointer:
    checkpointer.setup()
    graph = builder.compile(checkpointer=checkpointer)

    config = {"configurable": {"thread_id": "user-42"}}
    graph.invoke(
        {"messages": [{"role": "user", "content": "hi"}]},
        config,
    )

The feature that genuinely differentiates Redis is native TTL support. Checkpoints can expire automatically, which no other backend gives you out of the box:

from langgraph.checkpoint.redis.aio import AsyncRedisSaver

ttl_config = {
    "default_ttl": 60 * 24,   # expire checkpoints after 24 hours (minutes)
    "refresh_on_read": True,  # active conversations stay alive
}

async with AsyncRedisSaver.from_conn_string(
    REDIS_URI, ttl=ttl_config
) as checkpointer:
    await checkpointer.asetup()
    graph = builder.compile(checkpointer=checkpointer)

With refresh_on_read enabled, every time a thread is touched its expiry clock resets, so busy conversations persist while abandoned ones clean themselves up. If you have ever written a cron job to prune stale checkpoint rows out of Postgres, you will appreciate how much operational surface area this removes.

Where Redis shines. In-memory reads and writes make it the lowest-latency option, which matters when your graph checkpoints frequently — long graphs with many super-steps, or fan-out patterns that write many intermediate states. TTL-based expiry matches the natural lifecycle of session-scoped conversations. And if your stack already runs Redis for caching or queues, you are adding a use case, not a system.

Where Redis costs you. Durability is a configuration decision, not a default. Out of the box, Redis persistence (RDB snapshots and/or AOF) can lose recent writes in a crash depending on your fsync policy — acceptable for a chat session cache, unacceptable for a compliance-sensitive approval workflow that must never forget a pending human sign-off. Memory is also your storage budget: checkpoints hold full serialized graph state, and large states multiplied by many threads consume RAM quickly, which is priced very differently from disk. Finally, the module requirement narrows your hosting options compared to plain Redis.

The mental model: Redis treats checkpoints as fast, expiring session state. If your threads are long-lived records of business value, that model fights you. If they are ephemeral conversations, it fits perfectly.

Head-to-Head: How the Three Backends Actually Differ

Since the API surface is identical, choose on operational characteristics. Here is how the three compare on the dimensions that matter in practice.

  • Concurrency. SQLite serializes all writes behind a single lock — fine for one user, painful for many. Postgres handles high concurrent write volume as a matter of course. Redis is single-threaded per operation but operations are so fast that it sustains very high throughput; contention is rarely the issue.
  • Horizontal scaling. SQLite is machine-local, full stop. Postgres and Redis are both network services that any number of app replicas can share.
  • Durability. Postgres is the strongest: synchronous commits, WAL, replication, point-in-time recovery. SQLite is durable to its file, which is only as safe as the disk and your backup habit. Redis durability ranges from "quite good" to "best effort" depending on AOF configuration, and you should read your fsync settings before trusting it with irreplaceable state.
  • Latency. SQLite wins on paper with in-process file access. Redis is close behind over a local network. Postgres adds a network round trip plus transaction overhead. In LLM systems, all three are dwarfed by a single model call — do not let microbenchmarks drive this decision.
  • Lifecycle management. Redis has built-in TTLs. Postgres and SQLite require you to build pruning yourself with delete queries or the checkpointer's delete_thread method.
  • Operational burden. SQLite is zero-ops. Managed Postgres and managed Redis are both low-ops; self-hosting either is a real commitment.
  • Ecosystem alignment. Postgres pairs with PostgresStore for long-term cross-thread memory and is what LangGraph Platform runs. Redis has an equivalent RedisStore. SQLite keeps everything in one file, which is also its ceiling.

A simple decision rule that holds up well: single process and modest concurrency, use SQLite; multiple replicas or anything customer-facing, use Postgres; high-frequency checkpointing with session-scoped threads and an existing Redis footprint, consider Redis — with Postgres behind it for anything that must never be lost.

Swapping Backends Without Rewriting Your Graph

Because every checkpointer implements BaseCheckpointSaver, your graph code should never know which backend it is talking to. A clean pattern is a small factory driven by environment configuration:

import os
from contextlib import asynccontextmanager

@asynccontextmanager
async def make_checkpointer():
    backend = os.getenv("CHECKPOINT_BACKEND", "sqlite")

    if backend == "postgres":
        from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
        async with AsyncPostgresSaver.from_conn_string(
            os.environ["DATABASE_URL"]
        ) as cp:
            await cp.setup()
            yield cp

    elif backend == "redis":
        from langgraph.checkpoint.redis.aio import AsyncRedisSaver
        async with AsyncRedisSaver.from_conn_string(
            os.environ["REDIS_URL"],
            ttl={"default_ttl": 1440, "refresh_on_read": True},
        ) as cp:
            await cp.asetup()
            yield cp

    else:
        from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
        async with AsyncSqliteSaver.from_conn_string("checkpoints.db") as cp:
            yield cp

Now local development runs on SQLite with zero setup, CI runs on SQLite or in-memory, and production runs on Postgres or Redis — all with the same graph definition. This is the same single-module-swap philosophy we apply across the teachyou.ai stack, and it pays off the first time you need to test a production issue locally.

One caveat: swapping backends does not migrate existing data. Checkpoints written to SQLite do not appear in Postgres. If you need to carry live threads across a migration, you must read each thread's checkpoints from the old saver with list and write them to the new one with put — or, more pragmatically, accept that in-flight conversations reset at cutover and communicate that to users. For most chat products, draining old threads naturally over a deprecation window is far cheaper than a data migration.

Production Pitfalls That Bite Every Backend

A few failure modes come up repeatedly regardless of which backend you choose, and they are worth engineering around from day one.

  1. Forgetting `setup()`. Postgres and Redis savers both fail at runtime if their schema or indices do not exist. Wire setup() into your migration or startup path, not into a notebook cell you ran once and forgot.
  2. Compiling without a checkpointer, or invoking without a `thread_id`. Both fail quietly in the worst way: the graph runs fine but remembers nothing, or raises a ValueError about a missing thread when you first hit an interrupt(). Make thread_id a required parameter in your own service layer.
  3. Unbounded state growth inside checkpoints. Every checkpoint serializes your full state. If your messages channel grows without trimming, every super-step writes an ever-larger blob, and storage and latency degrade together. Add message trimming or summarization to the graph itself; the checkpointer will faithfully persist whatever bloat you give it.
  4. Unbounded checkpoint history. Separately from state size, the number of checkpoints per thread grows with every super-step. Decide on retention: TTLs in Redis, scheduled deletes in Postgres, periodic file compaction or thread deletion in SQLite.
  5. Serialization surprises. Checkpointers serialize state with LangGraph's serde layer, which handles LangChain message objects, dataclasses, and Pydantic models well — but arbitrary custom objects, open file handles, or client instances in state will break or bloat serialization. Keep state as plain data; reconstruct clients inside nodes.
  6. Storing secrets in state. Anything in state lands in your checkpoint store in serialized form. If tool outputs include API keys or PII, they are now in every checkpoint of that thread. Scrub sensitive values before they enter state, and treat the checkpoint database with the same access controls as any user-data store.
  7. Sync savers in async apps. Using SqliteSaver inside an async FastAPI handler blocks the event loop on every checkpoint write. Match the saver to your runtime: Async* variants for async apps, plain variants for scripts and sync workers.

None of these are exotic. They are the standard gap between "works in the demo" and "works for a thousand users," and closing that gap is mostly a matter of knowing the list.

Which Backend Should You Choose?

Pulling it all together, the decision comes down to three questions.

First, how many machines will run your graph? One machine, and SQLite stays on the table. More than one — now or plausibly within six months — and you need Postgres or Redis, because a local file cannot be shared.

Second, what happens if you lose recent checkpoints? If the answer is "a user repeats a message," Redis with sensible persistence settings is fine, and its TTLs will save you an entire class of cleanup work. If the answer is "an approval workflow silently loses a pending human decision," you want Postgres and its durability guarantees, full stop.

Third, what does your team already operate? A checkpointer is a stateful production dependency. If you have a managed Postgres instance humming along, adding checkpoint tables to it is nearly free. If Redis is already core to your stack, RedisSaver slots in naturally. The best backend on paper loses to the backend your team can debug at 2 a.m.

For most teams building customer-facing agents, the path we recommend is: prototype on SQLite, deploy on managed Postgres, and reach for Redis only when profiling shows checkpoint latency actually matters or when TTL-based session expiry is a genuine requirement. You will change your model provider, your prompts, and your graph topology many times. If you pick your persistence backend deliberately, it is the one piece you will rarely have to touch.

Persistence is also just the beginning — once checkpointing is in place, you unlock human-in-the-loop interrupts, time travel debugging, cross-thread memory stores, and fault-tolerant long-running agents, and each of those deserves the same production-minded treatment. If you want to go deeper with hands-on projects that wire up every one of these checkpointers against real graphs, our LangGraph Tutorial course on teachyou.ai walks through building a production agent from a blank file to a deployed, persistent, multi-user system — checkpoints, stores, interrupts, and all.