Agentic AI · LangGraph

Persistence & Threading

How a stateless agent — brilliant in a demo, broken in production — learns to remember: saving a snapshot after every step, keyed to a conversation, so it survives restarts, reboots, and days-long gaps.

Updated Jul 2026 · Read ~19 min · Session 8.1 (continuous CRM support-agent build)


// the big idea

An agent that forgets everything when its process ends is not a bug you patch later — it is architecturally wrong for a conversation. LangGraph fixes this with two primitives: a thread (a conversation's identity) and a checkpointer (a store that saves a full state snapshot around every node). Get the thread ID right and users stay isolated; get it wrong and one user reads another's data.

1.0

Notebook Summary

Copy this by hand. Everything here is expanded in the Detailed Notes below.

Recap · React loop & 3 safety layers
The problem
Stateless vs Stateful
Stateless (REST) Stateful (agent)
each request independent each turn builds on last
state in DB, not server the agent is the state
self-contained turn 2/3 meaningless w/o memory
Two primitives
Storage backends
Saver Where Use
MemorySaver RAM dict notebook/dev; dies w/ process
SqliteSaver local file single container / edge
Postgres / Mongo DB server distributed, multi-instance
Serialization & schema
Config & time travel
Failure modes

2.0

Detailed Study Notes

A customer messages your support agent at 9:14 a.m.: their subscription was double-charged. The agent looks up the account, finds the duplicate transaction, and begins explaining the refund. At 9:16 the customer steps away. They come back at 2:30 p.m. and type, "what do I need to do next?" — and the agent has no idea who they are. Every tool call, every fetched record, every step of reasoning is gone. It starts from zero, and the customer has to repeat everything. A human clerk with a notepad would have done better.

That gap between a clever demo and a system that works at 2:30 p.m. is the whole subject of this lesson. Before we get there, a quick recap of where the agent stands.

2.1

Recap: the React loop and its three safety layers

The agent reasons, then acts, in a loop — and three guards keep that loop from hurting itself. "React" here means reason + act: the model thinks about what it needs, calls a tool to get it, reads the result, and decides whether to think again or answer. The graph flows start → classify → agent ⇄ tool → respond → END, where the classifier labels the ticket (technical, billing, fraud, general) and routes it.

start classify agent tool respond END has tool call loop no tool call

fig 2.1 — the agent node runs the React loop; the tool ⇄ agent cycle repeats until no tool is needed.

circuit_breaker

Stops the loop after max_iterations (5 in the build). Without it, an agent can call tools forever.

context_trim

Before each LLM call, keep the original message plus the most recent ~12. Prevents context-window bloat.

fingerprint

A unique hash of each tool call + its args. If the same call is attempted twice, escalate instead of re-running.

⭐ TRICK

The fingerprint isn't just deduplication — it's a loop-detector that doubles as an escalation trigger. A repeated identical tool call almost always means the agent is stuck, so the same signal that blocks the wasted call also decides "hand this to a human." One mechanism, two jobs.

All three are real-but-local guards. They keep a single run healthy. What none of them address is what happens between runs — and that is where persistence begins.

2.2

Why stateless execution fails in production

When a process ends, everything it held in memory is erased — completely and without discrimination. Every Python variable, every object, every LangGraph state dictionary lives in RAM, in a region the operating system leased to that process. When the process terminates — finished normally, crashed, killed with Ctrl-C, or knocked out by hardware failure — the OS reclaims every byte. It does not ask which parts mattered. There is no partial survival.

This is not a defect. It is the same isolation that lets a machine run thousands of processes without their memory bleeding into each other. It is also exactly why a naive agent forgets.

Analogy · the amnesiac clerk

Picture two support reps with identical expertise and instant account lookups. The first is an amnesiac: at the end of every conversation their memory wipes, so a returning customer must re-explain everything. The second is a veteran who writes a case file after each call and reopens it next time. Today's naive agent is the amnesiac. Persistence turns it into the veteran — the checkpointer is the case file, the thread ID is the case number, the database is the filing cabinet.

⚠ PITFALL

"Just copy the whole memory and paste it back" doesn't work. A blind Ctrl-C of the process state is a disaster for an agent — you need a structured, serialized snapshot tied to the right conversation, not a raw dump of RAM. Persistence is a contract, not a copy.

In production, process restarts are not a rare accident — they're routine: rolling Kubernetes deployments, autoscaling events, infrastructure failures. A non-persistent agent mid-workflow, three tool calls deep, loses all of it on any restart. For an in-flight conversation, every restart is total amnesia. So this isn't a theoretical risk you might hit; without persistence it's a guaranteed production failure.

2.3

Stateless vs stateful: the architectural mismatch

The web is stateless by design; a conversation is not — and forcing one into the other breaks it. HTTP was built so each request carries everything needed to serve it, with the server remembering nothing about prior requests. That makes REST simple, scalable, and predictable: send a customer ID, get customer data back, done. It's the model behind essentially every large web service.

An agent conversation is the opposite. It's a sequence of exchanges where each one depends on the accumulated context of all the ones before. When a customer asks "what do I do next?", a correct answer depends on what they asked two minutes ago, which tool was called, and what it returned. You cannot make that stateless without destroying the intelligence of the interaction.

stateless · REST API

Each request independent

  • State lives in the database, not the server
  • Every call is self-contained
  • Powers Google, Amazon, most web services
stateful · agent workflow

Each turn builds on the last

  • The agent is the state
  • Without memory, turns 2 and 3 are meaningless
  • One step in a conversation spanning days & restarts
💡 TIP

The line to remember for an interview: "persistence is not a feature you add to an agent — it's the architectural prerequisite for the agent to be correct." A stateful agent still runs behind a stateless HTTP server; the difference is that the conversation state now lives in the checkpoint store instead of in the request.

2.4

Primitive one: the thread

A thread is a conversation's unique identity — a label that groups everything belonging to one conversation together. Despite the name, it is not an OS thread, a process, or a network connection. It is a logical identifier: the thread_id. It behaves like a foreign key — the address that tells each checkpoint which conversation it belongs to.

When you invoke the graph with a given thread_id, LangGraph loads the last checkpoint for that conversation and resumes. Invoke with a different id and you're in a different, isolated conversation. This is the thread-isolation guarantee: user 42's messages can never contaminate user 99's, because every write is tagged with its thread ID (often combined with a user ID into a composite key). It's also what lets you scale horizontally — ten instances of the same agent can serve ten conversations with no risk of cross-talk.

⚠ PITFALL — thread leakage (the classic prod bug)

Using a static or non-unique thread_id — say a hard-coded "default" or "123" left over from development — makes multiple users share one conversation. User A's state loads into User B's session, and A's data becomes visible to B. This is a real data-leakage vulnerability and a GDPR (General Data Protection Regulation) violation that can get a company sued. It's insidious because single-user testing looks perfect; it only detonates under real multi-user traffic. It has happened in early LangGraph deployments.

💡 TIP — what to actually use as a thread_id

Pick a value that is stable across a conversation but unique per conversation: a user ID from your auth system ("user-12345"), a web session ID ("session-abc"), or a ticket ID ("ticket-9981"). Never generate a random UUID per request — that mints a brand-new thread on every call, so nothing ever resumes. And with no thread_id at all, the checkpointer is present but useless: every invoke is anonymous, same behavior as MemorySaver.

user_42 turn 1 turn 2 user_99 turn 1 checkpoint store (keyed by thread_id) thread_id = user_42 → snapshots thread_id = user_99 → snapshots

fig 2.4 — each thread_id maps to its own isolated pile of snapshots. Wrong id ⇒ the lines cross ⇒ leakage.

2.5

Primitive two: the checkpointer

A checkpointer is an automatic state ledger: it snapshots the full state dict around every node and writes an immutable entry. It hooks into the execution engine and fires before a node runs (recording which node is about to run, the full state, the config) and again after it completes (recording the updated state). This is not configurable — you can't skip checkpointing for a node — and the ledger is append-only: prior snapshots are never overwritten or deleted. Every state the graph has ever been in is preserved, which is exactly what makes time travel possible.

⭐ TRICK — full snapshots, not deltas

LangGraph stores the entire state at every step, not the difference from the previous step. The cost is storage: a 5-node graph over a 50-turn conversation produces 250 checkpoints, each a full copy. The payoff is huge: any checkpoint is independently restorable. You can jump straight to checkpoint 347 without replaying 1–346 — which is what makes debugging and time travel practical.

user request→ ✓ save→ node 1→ ✓ save→ node 2→ ✓ save→ node 3→ ✓ save→ return

A snapshot is written before and after each node; the ledger only grows. On restart with the same thread: load the latest → resume. No replay needed.

2.6

Where snapshots live: choosing a saver

The checkpointer interface is fixed; only the backing store changes — and swapping it is one line of code. From the graph's perspective every saver is indistinguishable. That lets you develop against the fast in-memory saver, then switch to a durable store for production without touching graph logic.

Saver Stores in Survives restart? Best for
MemorySaver a Python dict in RAM No — dies with the process notebooks, local dev, fast & zero-dependency
SqliteSaver a local file on disk Yes — reload from the file single container or edge deployment
PostgresSaver a Postgres DB Yes — shared server distributed / multi-instance, indexed for scale
MongoDB (custom) a Mongo instance Yes — centralized horizontally-scaled prod, stateless containers
Analogy · restore your browser tabs

You've used a durable checkpointer without knowing it. Close your browser, reopen it, hit reopen closed tabs — everything comes back because the state was written to disk, not just held in memory. MemorySaver is a browser that forgets your tabs on quit; SqliteSaver and friends are the version that brings them back.

💡 TIP — SQLite vs a real DB server

SQLite wins when the agent runs on a single server, container, or edge device: zero setup, a local file, no extra infrastructure. Reach for Postgres or MongoDB when you have a distributed, multi-instance deployment — Kubernetes with rolling updates and autoscaling prefers stateless containers, so you can't trust local disk. A centralized DB is reachable by every instance and can be properly indexed on thread_id for high-traffic history reads.

2.7

Serialization and the schema contract

To save a snapshot, the checkpointer serializes state into a storable form — which means your state must be serializable. With MemorySaver this happens in memory; with SqliteSaver and PostgresSaver the state is serialized to JSON and written to a database column. That creates a contract: every field in your TypedDict state must be JSON-serializable — strings, ints, floats, booleans, lists, dicts. No arbitrary Python objects.

state.py — serializable state with a version field
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages

class SupportState(TypedDict):
    messages: Annotated[list, add_messages]  # JSON-safe list
    category: str
    iteration_count: int
    tool_results: dict
    schema_version: int                      # <-- guards against drift
⚠ PITFALL — schema drift (invisible in dev)

When you change the shape of your state — add a field, rename one — old checkpoints written under the old shape no longer match. This is one of the nastiest persistence failures because it's invisible during development: your dev checkpoints are always fresh, so it looks fine. In production, old checkpoints holding months of conversation history can be corrupted on load. It's the LLM-agent cousin of ML data drift.

⭐ TRICK — version + migrate, like a DB migration

Put a schema_version field in the state. On load, compare the checkpoint's version to the expected one; if they differ, run a migration function that transforms the old state into the new shape before proceeding. Treat state migrations with the same seriousness as database migrations — because that's exactly what they are.

2.8

Wiring it up: compile with a checkpointer, invoke with a thread

Attaching a checkpointer changes execution in two ways, and the thread config tells it which conversation you mean. When LangGraph sees a checkpointer on a compiled graph, then: (1) before the first node, it checks the store for an existing checkpoint under this thread_id — loads it if present, uses the provided input as-is if not; and (2) after every node, it saves the updated state. Wiring this into an existing build is famously small: only three lines change — create the saver, pass it to compile(), and add a thread_id to each invoke. The graph, nodes, and tools are untouched, and the support.db file is created automatically on first run.

⭐ TRICK — the Script A / Script B pattern

Split invocation by turn. Script A (first turn) sends the full initial state. Script B (every follow-up) sends only the new message — the checkpointer transparently loads the prior messages, customer_data, and tool fingerprints, then a reducer appends the new message. The agent receives the whole conversation without any extra code. In production that means the customer can close the browser, come back tomorrow, and the agent still remembers everything.

app.py — Script A (first turn) vs Script B (follow-ups)
from langgraph.checkpoint.sqlite import SqliteSaver   # dev: MemorySaver — one-line swap

# support.db is created automatically on first run; survives every restart
checkpointer = SqliteSaver.from_conn_string("support.db")
graph = builder.compile(checkpointer=checkpointer)     # the only line that changes

# thread config MUST be nested under "configurable"
config = {"configurable": {"thread_id": "user-arjun-001"}}

# Script A — first turn: send the full initial state
graph.invoke(build_initial_state("My account C-1002 is showing past due"), config)

# ...server restarts (routine k8s deploy)... hours later, same thread_id.
# Script B — follow-up: send ONLY the new message. The checkpointer reloads
# messages, customer_data, and tool fingerprints transparently.
graph.invoke({"messages": [("user", "My email is arjun@company.com")]}, config)
⚠ PITFALL — thread_id at the top level is silently ignored

Passing {"thread_id": "..."} at the top of the config instead of nesting it under configurable does not raise an error. LangGraph just ignores it: the graph starts from scratch every time, get_state_history is empty, and no checkpoints appear in the store. It's a silent failure — dangerous precisely because the graph keeps producing correct-looking responses while persisting nothing. The nesting exists so other configurable values (which model, which tool set) can coexist with the thread ID without naming conflicts.

2.9

Time travel: state history as a production capability

Because every step is stored, you can walk backward through a conversation, inspect any past state, and resume from it. Imagine an agent that's been live for two weeks; a customer reports it gave them wrong information three days ago that caused financial harm. With a non-persistent agent, the state that existed then is simply gone — at best you have scattered logs. With checkpointing, you load the customer's thread, enumerate its checkpoints, find the one from three days ago, and inspect exactly what the model saw, what tools were called, and what they returned. That's a full audit trail.

Method Returns Use it for
get_state(config) the current state (most recent checkpoint) "where does this conversation stand right now?"
get_state_history(config) an iterator over all checkpoints, newest → oldest time travel, debugging, audits
💡 TIP — history is newest-first

get_state_history returns checkpoints most-recent first by default. To read a conversation in the order it happened, wrap it: reversed(list(graph.get_state_history(config))).

Each entry from the history is a state snapshot with four parts. Keeping the first three straight is a common exam question — config and metadata look similar but do different jobs — and the fourth, next, is the quiet one that powers human-in-the-loop:

state

The full state dict at that moment — the actual conversation data (messages, category, account status). A complete snapshot frozen in time, not a diff and not a log.

config

The resumption handle: the thread_id + checkpoint_id you pass back to target this exact snapshot and resume from it.

metadata

The audit trail: step, source, and timestamp — which node produced this checkpoint and when. source == "loop" means a node completed.

next

Which node runs next. Empty ⇒ execution finished; a node name ⇒ execution paused there. This is how HITL interrupt-detection works.

time_travel.py — inspect and rewind
# current state
snapshot = graph.get_state(config)
print(snapshot.state["messages"])        # the data
print(snapshot.next)                      # () = done; ("agent_node",) = paused there

# walk the full history, newest first -> reverse for chronological
for snap in reversed(list(graph.get_state_history(config))):
    print(snap.metadata["step"], snap.metadata["source"])   # audit: which node, when
    print(snap.config["configurable"]["checkpoint_id"])     # resume handle

# rewind: re-invoke FROM a past checkpoint. Pass None as the input so the
# graph uses the checkpoint's saved state instead of overriding it.
old = {"configurable": {"thread_id": "user-arjun-001",
                        "checkpoint_id": "<id-from-history>"}}
graph.invoke(None, old)
💡 TIP — audit trails as a legal requirement

Time travel also enables human-in-the-loop after the fact: rewind to where the agent went wrong, correct the state, and replay the rest — the human reviews after the run, not blocking it at runtime. In regulated industries (financial services under FINRA/MiFID, healthcare under HIPAA, anything with e-discovery obligations), an audit trail of automated decisions isn't a nice debug feature — it can be a legal requirement.


2.10

Why this matters next: human-in-the-loop

Persistence is the foundation that lets an agent pause mid-run, wait for a human, and resume — and without a checkpointer there is nowhere to pause. A non-persistent graph runs start to finish with no way to stop before a consequential action (say, writing to a repo) and no way to inject a human decision. Because the ledger records snapshot.next and preserves every state, execution can halt before a write, save that state, let a human review the draft, and then resume from the exact checkpoint. That's how a later session wires up interrupt_before and Command(resume=...) — but it only works because the checkpoint ledger exists first.

💡 TIP — time travel forks, it doesn't erase

Re-invoking from a past checkpoint doesn't overwrite history — the original path still exists and time travel creates a fork. Both branches remain queryable. Nothing in the append-only ledger is ever lost, which is what makes "rewind, fix the state, regenerate" a safe production move.


2.11

Interview & practice Q&A

Why can't you just make an agent stateless like a REST endpoint?

REST works because each request is self-contained. A conversation is a sequence where each turn depends on the accumulated context of prior turns — "what do I do next?" is meaningless without the earlier messages, tool calls, and results. Removing state doesn't simplify the agent; it destroys the intelligence of the interaction.

What are LangGraph's two persistence primitives, and what does each do?

The thread is a conversation's identity — a logical thread_id that groups all of one conversation's checkpoints, acting like a partition key. The checkpointer is an automatic state ledger that saves a full snapshot around every node (before and after). The thread says which conversation; the checkpointer stores what happened in it.

Snapshots are full copies, not deltas. Why, given the storage cost?

Full snapshots make any checkpoint independently restorable — you can jump to checkpoint 347 without replaying 1–346. That independence is what makes debugging, audits, and resuming from an arbitrary point practical. The trade-off is larger writes and more storage on long conversations (a 5-node graph over 50 turns is 250 snapshots).

What is thread leakage and why is it a GDPR concern?

Thread leakage happens when a static or non-unique thread_id (like a hard-coded "default") is shared across users, so one user's state loads into another's session and their data becomes visible. That's a cross-user data breach — a GDPR violation. It's dangerous because single-user testing looks perfect and it only fails under real multi-user traffic.

You pass the thread_id but nothing persists and history is empty. What's wrong?

The thread_id is almost certainly at the top level of the config instead of nested under configurable. LangGraph silently ignores the top-level key: the graph starts fresh each time and saves nothing, yet still returns plausible responses. The correct shape is {"configurable": {"thread_id": "..."}}.

What is schema drift, and how do you defend against it?

Schema drift is when you change the state's shape (add/rename a field) so old checkpoints no longer match the new schema, corrupting stored history. It's invisible in dev because dev checkpoints are always fresh. Defend with a schema_version field plus a migration function that transforms old states to the new shape on load — treat it like a database migration.

When would you choose SqliteSaver over Postgres/Mongo, or vice versa?

Use SqliteSaver for a single server, container, or edge device — zero setup, a local file, no extra infrastructure. Use Postgres or MongoDB for distributed, multi-instance deployments (Kubernetes with autoscaling and rolling updates), where stateless containers can't rely on local disk and every instance needs one centralized, indexable store.

A state snapshot has state, config, metadata, and next. How do they differ?

state is the actual data — the full state dict at that point. config is the resumption handle (thread_id + checkpoint_id) you use to target and resume from that exact snapshot. metadata is the audit trail (step, source, timestamp): how this came to be. next tells you which node runs next — empty means execution finished, a node name means it paused there, which is how HITL interrupt-detection works.

Why does attaching a checkpointer mean you only send the new message?

Before running the first node, the checkpointer looks up the existing checkpoint for that thread_id and loads it. So the prior conversation is already restored — you only need to supply the new user turn, and LangGraph merges it into the reloaded state.


3.0

Key Takeaways

A process ending erases all in-memory state indiscriminately. For a conversation that spans restarts, that makes a naive agent architecturally wrong, not just buggy.

Persistence rests on two primitives: the thread (conversation identity via thread_id, a partition key) and the checkpointer (an append-only ledger that snapshots full state before and after every node).

Get the thread ID unique-per-conversation or risk cross-user leakage and a GDPR breach. Nest it under configurable or persistence silently does nothing.

Snapshots are full, JSON-serializable copies. Guard state changes with a schema_version + migration to survive drift. Swap savers (Memory → SQLite → Postgres/Mongo) in one line.

Full snapshots make time travel possible: inspect, rewind, and resume from any checkpoint — the basis for debugging, human-in-the-loop, and compliance audits.

The React loop's three guards (circuit breaker, context trim, fingerprint) keep a single run healthy; persistence is what carries the agent between runs.

thread_id → partition key checkpointer → append-only ledger snapshot before & after each node full copy, not delta immutable / forks, never erases MemorySaver = volatile SqliteSaver = single node Postgres/Mongo = distributed support.db, 3 lines change JSON-serializable state schema_version + migrate configurable.thread_id never per-request UUID Script A / Script B get_state_history → reversed state · config · metadata · next thread leakage = GDPR next = HITL pause point

Build it · Chapter 13 · Atlas

Build it — persistence & threading

A stateless agent forgets everything between runs. A checkpointer saves a full state snapshot after every step, keyed by a thread_id. Restart the process, pass the same id, and it resumes — while a different id stays a separate universe.

File atlas/checkpoint.pyRun python3 -m atlas.checkpointDeps Python 3 · stdlib only
First, teach the engine to persist. Give CompiledGraph.invoke two optional arguments — it loads this thread's state on the way in and snapshots on the way out:

engine.py — invoke gains a checkpointer:

# CompiledGraph.invoke — now aware of a checkpointer + thread_id.
def invoke(self, state, recursion_limit=25, trace=False,
           checkpointer=None, thread_id=None):
    if checkpointer and thread_id:                 # carry remembered state in
        state = {**checkpointer.load(thread_id), **state}
    else:
        state = dict(state)
    node = self.g.entry
    for _ in range(recursion_limit):
        if node == END:
            break
        update = self.g.nodes[node](state) or {}
        state.update(update)
        node = self._next(node, state)
        if checkpointer and thread_id:             # snapshot after each step
            checkpointer.save(thread_id, state)
    else:
        raise RuntimeError("recursion limit hit — cycle?")
    return state

atlas/checkpoint.py — the store, plus a two-thread demo:

"""Chapter 13 — persistence & threading: a stateless agent learns to remember.

A checkpointer saves a full state snapshot after every step, keyed by a
thread_id. Restart the process, pass the same thread_id, and the agent picks up
exactly where it left off — while a different thread_id stays fully isolated.
"""
import json
import os


class Checkpointer:
    def __init__(self, path=None):
        self.path = path
        self.store = {}
        if path and os.path.exists(path):
            with open(path) as f:
                self.store = json.load(f)

    def save(self, thread_id, state):
        self.store[thread_id] = dict(state)
        if self.path:
            with open(self.path, "w") as f:
                json.dump(self.store, f)

    def load(self, thread_id):
        return dict(self.store.get(thread_id, {}))


if __name__ == "__main__":
    from atlas.engine import StateGraph, END

    def step(state):
        return {"count": state.get("count", 0) + 1,
                "log": state.get("log", []) + [state.get("count", 0) + 1]}

    g = StateGraph().add_node("step", step).set_entry("step")
    g.add_edge("step", END)
    app = g.compile()

    cp = Checkpointer(path="/tmp/atlas_threads.json")

    # Thread "alice" runs three times; state survives across invocations.
    for _ in range(3):
        app.invoke({}, checkpointer=cp, thread_id="alice")
    # Thread "bob" is a separate universe.
    app.invoke({}, checkpointer=cp, thread_id="bob")

    print("alice:", cp.load("alice"))
    print("bob:  ", cp.load("bob"))

Run it — you'll see:

alice: {'count': 3, 'log': [1, 2, 3]}
bob:   {'count': 1, 'log': [1]}

This is one checkpoint of Atlas — a single project built across notes 04–14. Full code, setup, and the map of all checkpoints: Atlas — Build the notes ↗.