Skip to content

InMemorySaver + SqliteSaver: in-process and file-level checkpoints

源码版本1.2.9

Responsibilities

BaseCheckpointSaver defines the interface; the two most commonly used savers that actually implement it are InMemorySaver and SqliteSaver — the former stuffs the entire state into a defaultdict in process memory, the latter splits the same fields into two SQLite tables, checkpoints and writes. Together they cover development/debugging and single-machine persistence; you only move to PostgresSaver at production scale.

InMemorySaver lives in libs/checkpoint/langgraph/checkpoint/memory/__init__.py (InMemorySaver:33). Internally it uses three defaultdicts to hold three things: storage for the checkpoint body + metadata + parent_id, writes for (thread_id, ns, checkpoint_id) → (task_id, idx) → write records, and blobs for (thread_id, ns, channel, version) → serialized binary (storage / writes / blobs:68-83). This bucketing mirrors the three-table structure of Postgres/SQLite, just using dicts in place of tables.

SqliteSaver lives in libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py (SqliteSaver:45). It directly subclasses BaseCheckpointSaver[str], takes a sqlite3.Connection, creates two tables in setup() (<SrcLink path="libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py" lines="129-166" label="setup"/>), and runs put / put_writes through standard SQL INSERT OR REPLACE / INSERT OR IGNORE. It ships with a threading.Lock that serializes the single connection, so check_same_thread=False is safe.

Also, MemorySaver = InMemorySaver is a legacy alias (MemorySaver alias:631); the from langgraph.checkpoint.memory import MemorySaver you see in older code returns the exact same class.

Design motivation

  • InMemory's three buckets mirror the three tables: storage / writes / blobs (data members:68-83) correspond one-to-one with SQLite's checkpoints / writes tables and Postgres's checkpoints / checkpoint_writes / checkpoint_blobs three tables. InMemory stores blobs separately so that a channel value is serialized only when its version changes, instead of re-serializing every channel on every put of the checkpoint.
  • PersistentDict for optional on-disk persistence: the memory module also has a PersistentDict(defaultdict) (PersistentDict:634) that pickle-dumps the dict to a file and reloads it on restart. The factory parameter accepted by InMemorySaver.__init__ (__init__:85-99) is there so that when factory=PersistentDict a context manager is automatically attached — a compromise path for "debugging but want state to survive restarts".
  • SQLite uses WAL + a single lock: the first statement in setup() is PRAGMA journal_mode=WAL (WAL:141), so reads do not block writes; but writes are still serialized by self.lock because sqlite3.Connection is not thread-safe. The docstring explicitly says "does not scale to multiple threads" (note:48-54) — for concurrency, move to Postgres.
  • Write table distinguishes REPLACE from IGNORE: in SQLite, put_writes chooses INSERT OR REPLACE or INSERT OR IGNORE based on whether all writes are in WRITES_IDX_MAP (put_writes query:462-466): control-channel writes (ERROR / INTERRUPT / RESUME) can be overwritten; ordinary business writes that collide are ignored. This mirrors the if inner_key[1] >= 0 ... continue logic in InMemory (InMemorySaver.put_writes:499-509).
  • InMemory uses max(checkpoints.keys()) as latest: there is no explicit ORDER BY; it relies on checkpoint_id being monotonically increasing as a string (latest:282-283), so checkpoint_id must be a lexicographically comparable UUID6/UUID7, not a purely random UUID.

Key files

Data flow

InMemorySaver.put does both "split channel_values into blobs" and "write the body into storage" in one pass. You can clearly see how a saver decomposes a Checkpoint TypedDict into a three-layer structure:

python
c = checkpoint.copy()
thread_id = config["configurable"]["thread_id"]
checkpoint_ns = config["configurable"]["checkpoint_ns"]
values: dict[str, Any] = c.pop("channel_values")  # type: ignore[misc]
for k, v in new_versions.items():
    self.blobs[(thread_id, checkpoint_ns, k, v)] = (
        self.serde.dumps_typed(values[k]) if k in values else ("empty", b"")
    )
self.storage[thread_id][checkpoint_ns].update(
    {
        checkpoint["id"]: (
            self.serde.dumps_typed(c),
            self.serde.dumps_typed(get_checkpoint_metadata(config, metadata)),
            config["configurable"].get("checkpoint_id"),  # parent
        )
    }
)

(put body:448-464) Note that blobs is keyed by (thread_id, checkpoint_ns, channel, version), while storage is keyed by (thread_id, checkpoint_ns, checkpoint_id) — the two indexes do not overlap, so channel values are decoupled from the checkpoint body and channels whose version did not change are not re-serialized. On read-back, get_tuple calls _load_blobs to deserialize the blob for each (channel, version) pair in channel_versions and reassemble channel_values (_load_blobs:259-265), then packs it into the CheckpointTuple.

SqliteSaver follows the same logic, just with dicts replaced by SQL and the blob bucket replaced by the checkpoint BLOB column in the main table plus the value BLOB column in the writes table — there is no separate blob table; every channel value is serde'd directly into the checkpoint blob field:

python
cur.execute(
    "INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)",
    (
        str(config["configurable"]["thread_id"]),
        checkpoint_ns,
        checkpoint["id"],
        config["configurable"].get("checkpoint_id"),
        type_,
        serialized_checkpoint,
        serialized_metadata,
    ),
)

(SqliteSaver.put INSERT:425-436)

Boundaries and failure modes

  • InMemorySaver loses everything if the process dies: the docstring explicitly says "use InMemorySaver only for debugging or testing" (warning:40-44). For persistence, switch to Sqlite or Postgres.
  • InMemorySaver's latest relies on checkpoint_id lexicographic order: max(checkpoints.keys()) (max:282-283) assumes the ID is monotonically increasing. If a custom saver uses non-monotonic IDs, get_tuple without a checkpoint_id will fetch the wrong row.
  • SqliteSaver has a single connection and a single lock: the cursor() context manager acquires self.lock (cursor:181-189); concurrent writes are serialized, and a long transaction will block other reads. For concurrency you need Postgres's pipeline mode.
  • SqliteSaver hardcodes ? placeholder column order: the column order must match the INSERT INTO checkpoints (...) list exactly (INSERT columns:426); adding a column in a migration requires updating both places. The task_path column was added later — see the end of Postgres MIGRATIONS (task_path migration:90).
  • AsyncSqliteSaver is more than a to_thread wrapper: AsyncSqliteSaver:38 is a separate class; aput / aget_tuple / alist are all rewritten with aiosqlite (aput:509), not the base class's default asyncio.to_thread forwarding — so the async path can truly run concurrently, without being serialized by the global lock.
  • factory= with PersistentDict requires close() on exit: PersistentDict.sync() pickle-dumps to a file (sync:657); without calling __exit__ nothing is flushed. with InMemorySaver(factory=PersistentDict, ...) is the idiom.

Summary

InMemorySaver is the "three-bucket dict" reference implementation; SqliteSaver unfolds those same three buckets into SQLite's two tables + WAL. Together they cover debugging and single-machine persistence. The conventions around primary-key design, blob splitting, and negative indices for control channels all become concrete in these two implementations. For production scale see PostgresSaver; for the interface shape see BaseCheckpointSaver.

See official docs: LangGraph 文档 · README.