Persistence: Checkpointers, Threads, and Resumable Runs (LangGraph in Production, Module 06)

Module 6 of 16 20 min read Lab code ↗

Persistence: Checkpointers, Threads, and Resumable Runs (LangGraph in Production, Module 06)

This is Module 06 of LangGraph in Production, a free 16-module course that takes you from your first StateGraph to a deployed, durable autonomous coding agent on LangGraph v1.x. Start at Module 1 or browse the full syllabus.


The crash that erases everything

Picture this: Forge has just finished planning. The Planner produced a clean Plan targeting four files. The fan-out kicked off four Editor instances in parallel. Two of them are mid-way through rewriting their files. Then your SSH connection drops. Or your laptop runs out of battery. Or a system signal kills the process.

You restart the run.

graph = build_graph()
graph.invoke({"issue": same_issue, "repo_path": sandbox_path}, {"recursion_limit": 50})

Forge starts over. From scratch. The two files the Editors already rewrote — gone. The Planner will call the model again. The fan-out will restart. You pay for every token twice. The “autonomous coding agent” you just built is actually a stateless lambda that forgets everything the moment the process dies.

That is not production. That is a demo.

After this module, every superstep Forge completes gets written to a SQLite file on disk. Every run is scoped to a thread with a thread_id. If the process dies mid-fan-out, graph.invoke(None, config) picks up exactly where it stopped — without re-running a single upstream node, without duplicating any edits. You get that by passing twelve additional characters to builder.compile(). Zero lines of node logic change.


In this module

You’ll learn:

  • Wire a checkpointer into a compiled StateGraphSqliteSaver for disk, InMemorySaver for tests — including the .setup() call required on SQL backends.
  • Scope an execution to a thread via config={"configurable": {"thread_id": "..."}} and understand when the same thread_id resumes vs. starts fresh.
  • Read the current state of a thread at any time with graph.get_state(config) → a StateSnapshot, and interpret every field it exposes.
  • Navigate a thread’s checkpoint history with graph.get_state_history(config) and understand how checkpoint_ns and checkpoint_id address individual checkpoints.
  • Reason about pending writes — the partial writes already recorded when a superstep is interrupted in flight, and why resuming replays them without duplication.

You’ll build: Forge becomes crash-proof. SqliteSaver + thread_id scope every run to a persistent thread; get_state exposes the live snapshot; a kill-and-resume demo proves the graph picks up mid-fan-out without losing a single edit.

Concepts covered: CA6 — Persistence & threads (core). This module BUILDs CA6.1–CA6.6 of the LangGraph theory inventory.

Prerequisites: Modules 1–5. You need the cumulative forge/ package with the Send API fan-out and edits reducer from M5, a .env file with ANTHROPIC_API_KEY (or the Ollama fallback — cost $0), Python 3.12, and uv.


Where we are

  • ✅ M1 — First StateGraph, get_model, Forge & Tally scaffold
  • ✅ M2 — ForgeState, build_graph(), intake node
  • ✅ M3 — Triage router, test-fix loop, sandbox
  • ✅ M4 — Tools, create_agent, Planner with structured output
  • ✅ M5 — The Send API, fan-out, Editor×N, edits reducer
  • 👉 M6 — Persistence: SqliteSaver, threads, get_state, pending writes
  • ⬜ M7 — Human-in-the-loop: interrupt(), plan approval (interrupt() requires this checkpointer — 1 module away)
  • ⬜ M8 — Time-travel: replay from checkpoint_id, fork via update_state (reads these same checkpoints)
  • ⬜ M9–M16 — Durable execution, the Store, streaming, subgraphs, deployment, evaluation

Forge before M6: An agent that edits N files in parallel and loops test-fix in a sandbox — but every run is a bubble. Kill the process and the work vanishes. Forge after M6: Every superstep is persisted to SQLite, every run is scoped to a thread, and the graph resumes after a crash — but there is no way to pause mid-run and ask a human anything yet (that is M7), and replaying a past checkpoint to fork a different trajectory waits for M8.


Why persisting every superstep changes everything

The Pregel execution model, briefly

LangGraph runs your graph using a Pregel-style execution engine. Execution advances in supersteps: at each superstep, all currently active nodes run, their writes are merged into the state, and the next superstep begins. This is a Bulk Synchronous Parallel (BSP) model — a barrier between every round.

Without a checkpointer, those intermediate states live exclusively in process RAM. The moment the process exits, they disappear. With a checkpointer, LangGraph writes a complete checkpoint — a full snapshot of the state plus metadata — to the backend storage after every superstep completes, before the next superstep begins. That single sentence is the entire persistence contract.

sequenceDiagram
    participant R as Runtime (Pregel)
    participant C as Checkpointer (SqliteSaver)
    participant N as Nodes (superstep k)
    R->>N: execute superstep k
    N-->>R: writes (partial state updates)
    R->>C: write_checkpoint(state_k, metadata)
    C-->>R: checkpoint_id_k
    R->>N: execute superstep k+1
    note over R,C: crash here → next invoke resumes<br/>from state_k, not from zero

The key insight for Forge: the graph shape, the nodes, the edges, the fan-out logic — none of that knows about persistence. The checkpointer is wired in at compile time and operates orthogonally to everything else.

The three checkpointers you need to know

InMemorySaver (from langgraph.checkpoint.memory import InMemorySaver) — also exported as MemorySaver from the same module. Both names are valid in v1.x; you will see both in the wild. Survives within the current process only. Ideal for offline tests: the InMemorySaver + a fake chat model gives you a complete, deterministic test suite with zero external I/O. That is exactly what the Forge smoke tests use.

SqliteSaver (from langgraph.checkpoint.sqlite import SqliteSaver) — the default Forge checkpointer. Writes checkpoints to a .db file on disk. Survives process crashes. Easy to inspect with any SQLite tool. Requires one setup call before the first run:

from langgraph.checkpoint.sqlite import SqliteSaver

# The Forge checkpoint.py module wraps this pattern cleanly:
import sqlite3

conn = sqlite3.connect("forge_checkpoints.db", check_same_thread=False)
saver = SqliteSaver(conn)
saver.setup()  # create the checkpoint tables once — not at every run

The saver.setup() call creates the schema. You call it once at startup, not before every invoke. Forgetting it is the number-one bug people hit when they first add persistence to a LangGraph graph. The error looks like OperationalError: no such table: checkpoints — which is at least unambiguous.

PostgresSaver (from langgraph.checkpoint.postgres import PostgresSaver) — the production option for multi-worker deployments. Same interface as SqliteSaver; different backend. When you need multiple workers sharing a single thread’s state (multiple API replicas handling one long-running run), Postgres is the correct answer. This is theory only for Forge until M14; you do not need to set it up now.

Wiring the checkpointer into build_graph()

The Module 6 change to forge/graph.py is a single-parameter addition. Here is the complete file:

from langgraph.graph import END, START, StateGraph

from forge import nodes
from forge.state import ForgeState


def build_graph(checkpointer=None):
    builder = StateGraph(ForgeState)
    builder.add_node("intake", nodes.intake)
    builder.add_node("triage", nodes.triage)
    builder.add_node("planner", nodes.planner)
    builder.add_node("editor", nodes.editor)
    builder.add_node("apply", nodes.apply_edits, defer=True)
    builder.add_node("tester", nodes.tester)
    builder.add_node("fixer", nodes.fixer)

    builder.add_edge(START, "intake")
    builder.add_edge("intake", "triage")
    builder.add_conditional_edges(
        "triage", nodes.route_by_type,
        {"bug": "planner", "feature": "planner", "chore": "planner"},
    )
    builder.add_conditional_edges("planner", nodes.dispatch, ["editor"])
    builder.add_edge("editor", "apply")
    builder.add_edge("apply", "tester")
    builder.add_conditional_edges(
        "tester", nodes.route_after_tests,
        {"done": END, "give_up": END, "fixer": "fixer"},
    )
    builder.add_edge("fixer", "tester")
    return builder.compile(checkpointer=checkpointer)

Every node, every edge, every piece of fan-out logic: untouched. The only change is checkpointer=None in the signature and checkpointer=checkpointer in the compile() call. Calling build_graph() without arguments still works exactly as it did in M5 — the graph runs without persistence, useful for isolated tests that do not need threads.


Threads: one thread_id, one conversation

Why threads exist

With a checkpointer wired in, the graph can persist state — but persist it where? If you invoke the same compiled graph twice, LangGraph needs to know whether the second invocation continues the first run or starts a fresh one. The thread is the scoping mechanism.

A thread_id is an arbitrary string that identifies one continuous “session” of work. Every checkpoint written by a run is tagged with its thread_id. When you invoke again with the same thread_id, LangGraph finds the latest checkpoint for that thread and resumes from it. When you use a new thread_id, LangGraph finds nothing and starts fresh.

The v1.x syntax is straightforward:

# Scope a run to a thread.
config = {"configurable": {"thread_id": "forge-issue-42"}, "recursion_limit": 50}

# First invocation — starts the run and writes checkpoints as it goes.
result = graph.invoke(
    {"issue": issue_text, "repo_path": sandbox_path},
    config,
)

# ... process dies here ...

# Second invocation — same config, input=None — resumes from the last checkpoint.
result = graph.invoke(None, config)

input=None is the “resume” signal. LangGraph looks up the thread, finds that next is non-empty (there are still nodes waiting to run), and continues from where it stopped. If next is empty — the run already completed — invoke(None, config) returns the final state immediately without re-executing any nodes. That is a useful property: the same call works as both a resume trigger and a cheap state lookup for completed runs.

In Forge, thread_id is anchored to the issue identifier: "forge-issue-<id>". That makes it deterministic and stable — if the process crashes, you can reconstruct the thread_id from the issue number without having to store it anywhere.

Isolation between threads

ScenarioWithout checkpointerWith checkpointer + thread_id
Process crash mid-runAll work lostResumes from last checkpoint
invoke(None, config)Undefined behavior / errorResumes the thread
Two runs on the same graphShare nothing (fresh state each time)Scoped by thread_id; independent
Sharing one thread_id across two issuesN/ASecond run inherits first run’s state — corruption
get_state(config)Not availableReturns current StateSnapshot
interrupt() (M7)Impossible — no place to “park” the stateRequires exactly this checkpointer

The table at the bottom row is the teaser for M7: interrupt() is impossible without a checkpointer because there is nowhere to write the paused state. Everything you build in this module is the structural foundation for human-in-the-loop.

The forge/checkpoint.py factory module

Rather than scattering SqliteSaver construction across the codebase, Forge wraps it in a thin factory:

# forge/checkpoint.py
import sqlite3
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver


def in_memory() -> InMemorySaver:
    return InMemorySaver()


def sqlite(path: str = "forge_checkpoints.db") -> SqliteSaver:
    conn = sqlite3.connect(path, check_same_thread=False)
    saver = SqliteSaver(conn)
    saver.setup()  # create the checkpoint tables once
    return saver

Production code uses checkpoint.sqlite("forge_checkpoints.db"). Tests use checkpoint.in_memory(). The rest of the codebase never imports SqliteSaver directly — it only touches the interface returned by the factory. Swapping backends later is a one-line change.


Reading state: get_state, StateSnapshot, and the history API

Once you have persistence, you can read the state of a thread at any time — while it is running, after it completes, or after a crash. LangGraph exposes three APIs for this. They form a hierarchy: current state, full history, and surgical edits.

graph.get_state(config)StateSnapshot

from langgraph.types import StateSnapshot

snapshot: StateSnapshot = graph.get_state(config)

print(snapshot.values)         # dict: the full current state of the thread
print(snapshot.next)           # tuple of node names that will run next (empty if done)
print(snapshot.config)         # the exact config of this checkpoint (includes checkpoint_id)
print(snapshot.metadata)       # {"step": k, "source": "loop", "writes": {...}}
print(snapshot.created_at)     # ISO 8601 datetime of this checkpoint
print(snapshot.parent_config)  # config of the parent checkpoint (None for the first)
print(snapshot.tasks)          # pending tasks — non-empty when the run is in flight

Import StateSnapshot from langgraph.types, not from an internal module. Its fields:

  • values: the full state dict — all ForgeState fields at the time this checkpoint was written.
  • next: a tuple of node names that will execute on the next superstep. An empty tuple means the run is done.
  • config: the config dict that identifies this specific checkpoint, including checkpoint_id nested inside config["configurable"]["checkpoint_id"].
  • metadata: framework metadata including step (the superstep number, zero-indexed) and source.
  • created_at: when this checkpoint was written, as an ISO string.
  • parent_config: the config of the checkpoint one superstep earlier, useful for walking the history.
  • tasks: tasks that are currently pending within this superstep. Non-empty when the run crashed inside a superstep.

The next field is your primary signal for “is this run done?”. Check snapshot.next == () after invoking to confirm completion without inspecting the full state.

graph.get_state_history(config) — the checkpoint iterator

# Iterates newest-first over all StateSnapshots for the thread.
for snapshot in graph.get_state_history(config):
    step = snapshot.metadata.get("step", "?")
    print(f"step {step:>2}  next={snapshot.next}")

This gives you every checkpoint the thread has ever written, from most recent to oldest. In Module 8, you will use this iterator to implement time-travel: find the checkpoint just before a wrong decision, then replay or fork from it. For now, iterating get_state_history and printing step + next is enough to see the full execution trace — a useful debugging tool even before M8.

checkpoint_ns and checkpoint_id

Every checkpoint has two address components:

checkpoint_id: a UUID that uniquely identifies this specific checkpoint within the thread. Visible in snapshot.config["configurable"]["checkpoint_id"]. Used in M8 to target a specific point in history for replay.

checkpoint_ns: the namespace of the checkpoint. At the root graph level it is an empty string "". Inside a subgraph (a nested StateGraph compiled and added as a node), it takes the form "<node_name>:<uuid>". Think of it as the “address” component that tells you which graph layer wrote this checkpoint. Subgraph namespacing is the M12 topic — for now, know that checkpoint_ns + checkpoint_id together uniquely identify any checkpoint in any graph or subgraph.

Thread checkpoints visualized

graph LR
    I["invoke(input, config)"] --> CP0["checkpoint 0\nstep=0\nnext=['triage']"]
    CP0 --> CP1["checkpoint 1\nstep=1\nnext=['planner']"]
    CP1 --> CP2["checkpoint 2\nstep=2\nnext=['editor']"]
    CP2 --> CRASH["crash here"]
    CP2 -.->|"invoke(None, config)"| CP3["checkpoint 3\nstep=3\nnext=['apply']"]
    CP3 --> CP4["checkpoint 4\nstep=4\nnext=['tester']"]
    CP4 --> CP5["checkpoint 5\nstep=5\nnext=[]\nDONE"]
    style CRASH fill:#ff6b6b,color:#fff

Each arrow to a checkpoint is one superstep completing. The dashed arrow shows the resume path: invoke(None, config) after the crash picks up from checkpoint 2 (the last one written) and continues forward.


Pending writes: what happens mid-superstep

There is a subtler failure mode worth understanding carefully — one that directly affects Forge’s fan-out from M5.

The scenario

The fan-out Send from M5 spawns multiple Editor nodes in a single superstep. Suppose Forge is editing three files. Editor_0, Editor_1, and Editor_2 all start in parallel within superstep 3. Editor_0 finishes and submits its FileEdit. Editor_1 finishes and submits its FileEdit. Then Editor_2 crashes — the process dies with Editor_2’s work incomplete.

What happened to Editor_0’s and Editor_1’s writes before the crash?

How LangGraph handles it

LangGraph writes pending writes to the checkpointer incrementally, as each node inside a superstep completes its work. These are partial writes — they do not finalize the superstep checkpoint yet, but they are durably stored. The checkpoint for superstep 3 carries the pending writes from Editor_0 and Editor_1.

When you call graph.invoke(None, config) to resume:

  1. LangGraph loads the last checkpoint (superstep 2, before the fan-out started) and the pending writes from superstep 3.
  2. It identifies which nodes in superstep 3 have already submitted their writes (Editor_0, Editor_1).
  3. Those nodes are NOT re-invoked. Their writes are replayed directly from the stored pending writes.
  4. Editor_2 is re-invoked from its input state.
  5. The edits: Annotated[list[FileEdit], operator.add] channel receives all three FileEdit objects — two from pending writes, one from the fresh Editor_2 call — with no duplicates.

The result: three FileEdit objects in edits, exactly as if the crash never happened.

Why this matters for a code-writing agent

Forge does not just track state in memory — it writes files to the sandbox. The sandbox copy-in-tempdir design (the L8 decision from the architecture) means edits land in an isolated directory. With pending writes protecting the fan-out, even a crash in the middle of parallel editing leaves the sandbox in a consistent state on resume. Editor_0 and Editor_1 wrote their files before the crash. Editor_2 re-runs and writes its file. The Tester then runs on the complete set of edits.

Without pending writes, you would have to either re-run all editors (wasting tokens and risking state drift) or implement your own idempotency layer. LangGraph handles it for you at the framework level.

⚠️ Common misconception: config["configurable"] and the context vs. thread scoping confusion

{"configurable": {"thread_id": "forge-issue-42"}} is the correct v1.x syntax for thread scoping — it has always been correct and remains correct. This is a LangGraph framework parameter, not your application context.

What changed in v0.6 is how you pass application-level context — things like user_id, tenant_id, or repo_url that your code needs at runtime. In v0.x you could stuff those into config["configurable"] via config_schema. That usage is deprecated. Application context now passes through context_schema + Runtime (covered in M7+). Do not confuse the two: thread_id lives in config["configurable"] because it is a LangGraph persistence parameter; your app data lives in context_schema.

Second trap: searching for a checkpoint_during=True/False parameter to control checkpoint frequency. That parameter does not exist in v1.x. It was removed. Checkpoint granularity is controlled by the durability= parameter on invoke / stream ("exit" / "async" / "sync"). That is the M9 topic. In M6, the default behavior ("async") is correct — LangGraph writes checkpoints as nodes complete, which is exactly what you want for crash recovery.


Hands-on lab: Build it

Step 0 — Add the dependency

# pyproject.toml — add to [project.dependencies]
"langgraph-checkpoint-sqlite~=3.1",

Run uv sync to pull langgraph-checkpoint-sqlite 3.1.0 and update uv.lock. The import path is:

from langgraph.checkpoint.sqlite import SqliteSaver

Step 1 — forge/checkpoint.py — the factory module

Create forge/checkpoint.py with the two factory functions shown above. This is the only new file in M6. Every other file in forge/ is inherited unchanged from M5.

Step 2 — forge/graph.py — add checkpointer=None

The diff from M5 is two tokens: checkpointer=None in the signature, checkpointer=checkpointer in builder.compile(). The full file is shown in the pedagogy section above. No node, no edge, no state field changes.

Step 3 — forge/run.py — the thread-aware entry point

The run module provides three functions that the CLI and tests call:

# forge/run.py
from forge.graph import build_graph


def _config(thread_id: str) -> dict:
    return {"configurable": {"thread_id": thread_id}, "recursion_limit": 50}


def run_forge(issue: str, repo_path: str, *, thread_id: str = "default", checkpointer=None) -> dict:
    """Start a Forge run on a thread."""
    graph = build_graph(checkpointer=checkpointer)
    return graph.invoke({"issue": issue, "repo_path": repo_path}, _config(thread_id))


def resume(thread_id: str, checkpointer) -> dict:
    """Resume a crashed/paused run from its last checkpoint (input=None)."""
    graph = build_graph(checkpointer=checkpointer)
    return graph.invoke(None, _config(thread_id))


def get_state(thread_id: str, checkpointer):
    """Inspect the latest persisted state of a thread."""
    return build_graph(checkpointer=checkpointer).get_state(_config(thread_id))

The _config helper enforces the thread_id convention. resume() passes None as the first argument to invoke — that is the v1.x resume signal. get_state() calls the compiled graph’s .get_state() directly.

Step 4 — Run the smoke tests offline

uv run pytest tests/ -q

The test suite runs fully offline with fake chat models and InMemorySaver. The Tally pytest suite runs for real inside the sandbox (deterministic, no API needed). You should see all module tests pass:

........................................................
XX passed in X.XXs

Step 5 — Understand what test_module_06.py proves

The M6 tests verify three things: the factory functions return the right types, SqliteSaver actually creates a file, and the core persistence mechanics work. The key test is test_checkpointer_persists_threads_and_history:

def test_checkpointer_persists_threads_and_history():
    saver = checkpoint.in_memory()
    graph = _counter_graph(saver)
    cfg_a = {"configurable": {"thread_id": "a"}}

    graph.invoke({"n": 0}, cfg_a)
    graph.invoke({"n": 0}, cfg_a)  # second run on the SAME thread accumulates
    state_a = graph.get_state(cfg_a)
    assert state_a.values["n"] == 2

    history = list(graph.get_state_history(cfg_a))
    assert len(history) >= 2  # a checkpoint per super-step

    # A different thread is isolated.
    state_b = graph.get_state({"configurable": {"thread_id": "b"}})
    assert state_b.values.get("n") is None

Notice: the counter uses Annotated[int, operator.add] — the same reducer pattern as edits in ForgeState. Invoking the same thread_id twice accumulates the counter because the second invoke continues the thread state where it left off. Thread "b" is completely isolated — state_b.values.get("n") is None.

Step 6 — The crash-and-resume integration test

The integration test in test_pipeline.py proves the full Forge graph survives a crash inside the Tester and resumes correctly:

def test_resume_after_crash(monkeypatch, make_routed_model):
    # ... setup: seed repo, mock Planner, mock model ...

    calls = {"n": 0}

    def flaky(repo_path, timeout=120):
        calls["n"] += 1
        if calls["n"] == 1:
            raise RuntimeError("simulated crash inside the Tester")
        return real_run(repo_path, timeout)

    monkeypatch.setattr(sandbox, "run_tests", flaky)

    saver = checkpoint.in_memory()
    graph = build_graph(checkpointer=saver)
    cfg = {"configurable": {"thread_id": "t1"}, "recursion_limit": 50}

    with pytest.raises(Exception):
        graph.invoke({"issue": "...", "repo_path": repo}, cfg)

    # Resume from the last checkpoint.
    out = graph.invoke(None, cfg)
    assert out["test_result"].passed is True
    assert calls["n"] == 2  # Tester ran twice; upstream nodes did NOT re-run

The assertion calls["n"] == 2 is the key proof. The Tester ran once (and crashed) before the resume, and once after the resume. The Planner and Editors did not re-run — their state was loaded from the checkpoints written before the crash. The fix was already applied to report.py when the crash happened, and it is still there on resume.

Step 7 — Inspect state history interactively

After running a completed thread, try this snippet:

from forge.checkpoint import sqlite
from forge.graph import build_graph

saver = sqlite("forge_checkpoints.db")
graph = build_graph(checkpointer=saver)
config = {"configurable": {"thread_id": "forge-issue-42"}}

# Inspect current state
snap = graph.get_state(config)
print("next:", snap.next)           # () — run is done
print("step:", snap.metadata["step"])
print("checkpoint_id:", snap.config["configurable"]["checkpoint_id"])

# Walk the history newest-first
for s in graph.get_state_history(config):
    step = s.metadata.get("step", "?")
    print(f"  step {step:>2}  next={s.next}")

Output from a completed run looks like:

next: ()
step: 5
checkpoint_id: 1efb4a2c-...

  step  5  next=()
  step  4  next=('tester',)
  step  3  next=('apply',)
  step  2  next=('editor',)
  step  1  next=('planner',)
  step  0  next=('triage',)

In M8, you will take a checkpoint_id from this list, pass it back to graph.invoke as the starting point, and fork a different path from that moment in history. For now, just see that the full history is there.

Try it yourself

  1. Run Forge with thread_id="forge-issue-42", then run again with the same thread_id. Observe that the second call completes immediately (the run is done; LangGraph returns the final state from the checkpoint). Now try with a new thread_id — Forge starts fresh.

  2. Start a run, kill it mid-way with Ctrl-C (wait until you see at least one superstep print), then resume with graph.invoke(None, config). Count the model calls before and after — the nodes that completed before the kill did not re-run.

  3. Swap checkpoint.sqlite(...) for checkpoint.in_memory() in the tests. Confirm that all offline tests still pass with no .db file created. This is the two-backend test that validates the factory abstraction.


In production

Choosing the right backend. SqliteSaver is the right choice for single-process deployments: one developer machine, one Forge process, one .db file per agent (or one shared file). It is durable against crashes, easy to inspect with any SQLite viewer, and requires zero infrastructure. If you run Forge as a hosted API with multiple worker replicas, you need PostgresSaver — every worker needs to read and write the same thread’s state, which SQLite cannot do across process boundaries.

Design your thread_id for recoverability. Use a deterministic, stable identifier tied to your business entity — "forge-issue-42", "forge-pr-117", "forge-session-<user>-<timestamp>". A random UUID is fine for isolation, but makes crash recovery harder: if the process dies, you need to look up the thread_id from somewhere before you can resume. Anchor it to something you already know.

Checkpoint size grows with messages. The add_messages reducer on ForgeState.messages appends; it never truncates. After a long run with many Planner/Editor/Fixer cycles, messages can become large — and every checkpoint stores the full state, so checkpoint size grows proportionally. In long-running production agents, you will want to prune the message history before it balloons. Context window management and message pruning are M15 topics.

Cost. SqliteSaver is free — it is local disk I/O. PostgresSaver in production adds infrastructure cost but no per-checkpoint charge. The checkpointer adds zero LLM calls; persistence is pure storage overhead.

The M7 horizon. Everything you just built is the structural prerequisite for interrupt(). In M7, Forge will pause mid-run, write its state to the checkpointer, and wait for a human to approve the plan before the Editors start. That pause is only possible because the checkpointer exists. Module 8 then reads the same checkpoints to implement time-travel — replaying a run from any past superstep.


Mastery corner

What to really understand here

CA6.1 — Checkpointers: Three backends, same interface. InMemorySaver (MemorySaver) for tests; SqliteSaver for disk-durable dev and single-process production; PostgresSaver for multi-worker production (theory). All are passed to builder.compile(checkpointer=...). SQL backends require .setup() once before first use.

CA6.2 — Threads and thread_id: A thread is a run scoped by thread_id. Same thread_id → resume from last checkpoint; new thread_id → fresh run. invoke(None, config) → resume. The thread_id should be anchored to a stable business identifier.

CA6.3 — Checkpoint per superstep: One checkpoint is written per BSP barrier. The checkpoint is the complete state at that moment. A crash between superstep k and k+1 is recoverable: resume loads checkpoint k and continues from there.

CA6.4 — checkpoint_ns / checkpoint_id: checkpoint_id uniquely identifies a checkpoint within a thread. checkpoint_ns is "" at the root graph, "<node>:<uuid>" inside a subgraph. Together they form the full address of any checkpoint.

CA6.5 — State inspection APIs: get_state(config) → current StateSnapshot. get_state_history(config) → newest-first iterator of all snapshots. update_state(config, values) → write an additional checkpoint (time-travel foundation, covered in M8).

CA6.6 — Pending writes: Partial writes from nodes that completed within an interrupted superstep. Stored durably. Replayed without re-invoking the node on resume. Protects the fan-out: crashed editors do not cause duplicate or lost edits.


Questions

Q1. Your team deploys Forge on a developer’s laptop for solo use, then plans to scale to a hosted API with three worker replicas in production. Which checkpointer setup is correct for each environment?

A) InMemorySaver on the laptop, SqliteSaver in production. B) SqliteSaver on the laptop (calling .setup() once), PostgresSaver in production. C) SqliteSaver on the laptop, SqliteSaver in production (one file per replica). D) PostgresSaver on the laptop for consistency, PostgresSaver in production.


Q2. You add SqliteSaver to Forge for the first time. On the first graph.invoke() call, Python raises OperationalError: no such table: checkpoints. What is the cause and the fix?

A) The langgraph-checkpoint-sqlite package is not installed. Run uv add langgraph-checkpoint-sqlite~=3.1. B) SqliteSaver requires a Postgres connection string, not a file path. C) You did not call checkpointer.setup() before the first invoke. Call it once at startup. D) The SQLite file is read-only. Change file permissions with chmod 644.


Q3. Forge processes two different issues simultaneously: issue #41 and issue #42. A developer sets both to use thread_id="forge-default". What happens, and what is the correct convention?

A) LangGraph raises a ThreadConflictError — thread IDs must be unique per process. B) The second invocation gets a fresh state because LangGraph detects a different issue input. C) The second invocation resumes the first run’s state, corrupting both runs. Use thread_id="forge-issue-<N>" to isolate runs. D) Both runs complete correctly because the recursion_limit guards against state collision.


Q4. An external orchestrator wants to check whether Forge is still in the planning phase or has already dispatched the Editors, without waiting for the run to finish. Which API and which field answer that question?

A) graph.get_state(config).metadata["source"] — it changes from "input" to "loop" when editing starts. B) graph.get_state(config).next — it contains the names of the next nodes to execute; ('editor',) means planning just finished. C) graph.get_state_history(config) — iterate until you find a snapshot where values["plan"] is not None. D) graph.get_state(config).tasks — check if any task has name="editor".


Q5. Forge fans out three Editors in one superstep. Editor_0 and Editor_1 complete and submit their FileEdit objects. Editor_2 crashes the process. You call graph.invoke(None, config). Which of the following accurately describes what happens?

A) All three Editors re-run to ensure consistency. The edits list ends up with six FileEdit objects (duplicates), which the reducer de-duplicates. B) Editor_0 and Editor_1 do not re-run; their writes are replayed from pending writes. Editor_2 re-runs. edits ends up with three FileEdit objects, no duplicates. C) The resume starts from the checkpoint before the fan-out superstep, so all three Editors re-run. edits ends with three objects. D) Editor_2 alone re-runs. edits ends up with one FileEdit because the operator.add reducer was reset by the crash.


Answers

A1 — B. SqliteSaver with .setup() is the right choice for single-process disk-durable use. It survives process crashes and requires no infrastructure. In production with multiple workers, you need PostgresSaver because workers cannot share a SQLite file across process boundaries. InMemorySaver does not survive process exit — it is for tests, not for a developer who closes their terminal.

A2 — C. SqliteSaver.from_conn_string() (or SqliteSaver(conn)) creates the saver object but does not create the database schema. You must call saver.setup() once at startup. The symptom — OperationalError: no such table: checkpoints — is unambiguous. The package being installed is not the issue; the missing .setup() call is.

A3 — C. Two runs sharing a thread_id means the second run inherits the state from the first. LangGraph sees a live thread with existing checkpoints and continues from the last one — it does not detect that the issue input is different. The convention is "forge-issue-<N>": anchor the thread_id to a stable business identifier so each issue has its own isolated thread.

A4 — B. graph.get_state(config).next returns a tuple of node names that will execute in the next superstep. If next == ('planner',) the run is still pre-planning; if next == ('editor',) the plan is done and Editors are about to run; if next == () the run is done. metadata["step"] gives the superstep number but does not tell you which phase you are in semantically. tasks is non-empty when the run is actively in flight between checkpoints, which is a narrower case.

A5 — B. LangGraph stores pending writes — the writes from Editor_0 and Editor_1 — durably as they complete, before the superstep checkpoint is finalized. On resume, it replays those pending writes without re-invoking the nodes. Only Editor_2, which had not submitted its write, re-runs. The edits channel receives all three FileEdit objects (two from pending writes, one from the fresh Editor_2 call) with no duplicates. The operator.add reducer accumulates; it does not de-duplicate, but pending-write replay is idempotent by design.


Traps to avoid

Trap 1 — checkpoint_during= does not exist in v1.x. If you find blog posts or older documentation referring to checkpoint_during=True or checkpoint_during=False, they describe a parameter that was removed before LangGraph 1.0 GA. Do not use it. The v1.x mechanism for controlling when checkpoints are written is the durability= parameter on invoke / stream ("exit" / "async" / "sync"). That is the M9 topic — in M6, the default ("async") is correct.

Trap 2 — forgetting .setup() on SQL backends. SqliteSaver(conn) does not create the schema tables. Calling saver.setup() once at startup is required. The error — OperationalError: no such table: checkpoints — only appears at first invocation, which can be confusing if you test in a cleaned environment that works fine but the real environment has no prior .db file.

Trap 3 — thread_id is a framework parameter, not application context. {"configurable": {"thread_id": "..."}} is the correct v1.x syntax for thread scoping. It has not changed and is not deprecated. What changed in v0.6 is that application-specific data (user IDs, repo URLs, tenant identifiers) should no longer be passed through config["configurable"] using config_schema. That context now flows through context_schema + Runtime. Thread scoping and app context both use config["configurable"] as a transport dict — their roles are different. If you conflate them, you will pass app data through thread_id-style config and wonder why it does not arrive correctly in M7.


Key takeaways

  • A checkpointer writes the complete Forge state after every superstep — SqliteSaver for disk (dev and single-process production), PostgresSaver for multi-worker production (theory); .setup() is required once before first use on SQL backends.
  • A thread (thread_id) scopes an entire run. Same thread_id → resume from last checkpoint; new thread_id → fresh start. invoke(None, config) is the resume signal.
  • graph.get_state(config) returns a StateSnapshot: the full current state, the next nodes to run (next), the checkpoint_id, and pending tasks. snapshot.next == () means the run is complete.
  • get_state_history(config) iterates all snapshots newest-first — the same iterator that M8 uses for time-travel and trajectory debugging.
  • checkpoint_ns + checkpoint_id together address any checkpoint precisely, including checkpoints inside nested subgraphs (the M12 topic).
  • Pending writes make the fan-out from M5 crash-safe: partial writes from completed nodes in an interrupted superstep are replayed without re-running those nodes and without duplicating their output in the edits reducer.
  • Persistence is orthogonal to the graph logic: zero node functions, zero edges, zero state fields changed in M6. The checkpointer is wired in at compile() time and the rest of the code is unaware of it.

What’s next

This is Module 06 of LangGraph in Production, a free 16-module course that takes you from your first StateGraph to a deployed, durable autonomous coding agent on LangGraph v1.x. Start at Module 1 or browse the full syllabus.

Want the full LangGraph mastery question bank (interview-style) and the next module in your inbox? Subscribe here — it’s free, like everything in this series.

Forge can now survive a crash and resume — but there is still no way to pause mid-run and ask a human to approve the plan before editing starts. Module 7 adds interrupt() on top of this checkpointer to make plan approval and PR approval real human decisions — the four HITL patterns, Command(resume=), and the idempotence trap that trips most people on their first implementation.

Links:


References

  1. LangGraph Persistence documentation — checkpointers, threads, and state history. https://langchain-ai.github.io/langgraph/concepts/persistence/ (as of June 2026)
  2. langgraph-checkpoint-sqlite package — SqliteSaver and AsyncSqliteSaver. https://pypi.org/project/langgraph-checkpoint-sqlite/ (3.1.0 as of June 2026)
  3. langgraph.types.StateSnapshot — fields values, next, config, metadata, created_at, parent_config, tasks. https://langchain-ai.github.io/langgraph/reference/types/#langgraph.types.StateSnapshot (as of June 2026)
  4. LangGraph Time Travel documentation — get_state_history, update_state, replay from checkpoint_id. Preview of M8. https://langchain-ai.github.io/langgraph/concepts/time-travel/ (as of June 2026)
  5. LangGraph How-to: Create a custom checkpointer. https://langchain-ai.github.io/langgraph/how-tos/persistence/ (as of June 2026)
  6. Lab repository — module-06. https://github.com/dupuis1212/langgraph-prod-labs/tree/main/module-06 (as of June 2026)