InMemorySaver + SqliteSaver: in-process and file-level checkpoints
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'scheckpoints/writestables and Postgres'scheckpoints/checkpoint_writes/checkpoint_blobsthree tables. InMemory stores blobs separately so that a channel value is serialized only when its version changes, instead of re-serializing every channel on everyputof the checkpoint. PersistentDictfor optional on-disk persistence: the memory module also has aPersistentDict(defaultdict)(PersistentDict:634) that pickle-dumps the dict to a file and reloads it on restart. Thefactoryparameter accepted byInMemorySaver.__init__(__init__:85-99) is there so that whenfactory=PersistentDicta 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()isPRAGMA journal_mode=WAL(WAL:141), so reads do not block writes; but writes are still serialized byself.lockbecausesqlite3.Connectionis 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_writeschoosesINSERT OR REPLACEorINSERT OR IGNOREbased on whether all writes are inWRITES_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 theif inner_key[1] >= 0 ... continuelogic 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
InMemorySaver class definition:33-83— class declaration + the three defaultdict fieldsstorage/writes/blobs.InMemorySaver.put:427-471— splits channel_values into blobs, writes the checkpoint body into storage, returns the new config.InMemorySaver.put_writes:473-509— skips existing negative-idx control writes; ordinary writes are indexed by (task_id, idx).InMemorySaver.get_tuple:236-316— exact lookup by checkpoint_id, ormax(keys())for the latest, then assembles aCheckpointTuple.MemorySaver alias:631— legacy alias, equivalent toInMemorySaver.SqliteSaver class:45-95— wrapssqlite3.Connectionwith athreading.Lockfor serialized writes.SqliteSaver.setup:129-166— creates tables + enables WAL.SqliteSaver.get_tuple:191-263— two prepared SELECTs: exact lookup bycheckpoint_id, orORDER BY checkpoint_id DESC LIMIT 1for the latest, then JOIN writes.SqliteSaver.put:387-443—INSERT OR REPLACE INTO checkpoints; metadata is JSON-serialized to bytes.AsyncSqliteSaver:38— async version;aput/aget_tuple/alistare rewritten with aiosqlite.
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:
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:
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_tuplewithout a checkpoint_id will fetch the wrong row. - SqliteSaver has a single connection and a single lock: the
cursor()context manager acquiresself.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 theINSERT INTO checkpoints (...)list exactly (INSERT columns:426); adding a column in a migration requires updating both places. Thetask_pathcolumn was added later — see the end of Postgres MIGRATIONS (task_path migration:90). - AsyncSqliteSaver is more than a
to_threadwrapper:AsyncSqliteSaver:38is a separate class;aput/aget_tuple/alistare all rewritten with aiosqlite (aput:509), not the base class's defaultasyncio.to_threadforwarding — so the async path can truly run concurrently, without being serialized by the global lock. factory=with PersistentDict requiresclose()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.