teachyou.ai academy
← All posts
LangGraph

LangGraph Checkpointing and Memory: Persisting Agent State

Pramod Dutta · Jun 14, 2026 · 15 min read

Your agent has been running for four minutes, has called six tools, has burned through a chain of reasoning that cost real tokens, and is one step away from finishing — and then the process dies. Or a human needs to approve a step before the agent can continue. Or the API you're calling times out and the whole container restarts. If you have not designed for this moment, the agent starts over from nothing. Every intermediate decision, every tool result, every partial plan is gone. This is the problem checkpointing exists to solve, and it is one of the most underrated pieces of infrastructure in agent engineering. Most tutorials show you how to build a graph that runs once, top to bottom, in a single process. Production agents don't get that luxury — they crash, they wait on humans, they serve thousands of concurrent users, and they need to be debugged after the fact. This article is about the mechanics of LangGraph checkpointing and memory: what actually gets captured, how backends differ, how resumption works at the wire level, and how to use checkpoints for things beyond crash recovery — like time-travel debugging and multi-user session persistence.

Why statelessness breaks down for real agents

A pure function is stateless by design — you call it, it computes, it returns. Early LLM demos followed the same shape: send a prompt, get a completion, done. But an agent is not a function call, it's a process. It might loop through a plan-act-observe cycle a dozen times. It might call five tools in sequence, some of which take seconds or minutes. It might need a human to approve a destructive action like sending an email or executing a trade. None of these are single-shot operations, and all of them can be interrupted.

Consider three failure modes that are completely ordinary in production, not edge cases:

  • Process crashes. Your server restarts for a deploy, an OOM kill happens, a dependency throws an unhandled exception three tool-calls deep into a three-hour research task.
  • Human-in-the-loop pauses. The agent needs someone to review a generated SQL query before it runs against production, or approve a refund before it's issued. The agent has to stop, wait — possibly for hours — and then continue exactly where it left off.
  • Long-running, multi-session work. A coding agent works through a large refactor over multiple sittings. A customer support agent needs to remember a conversation from yesterday when the same user comes back today.

Without persisted state, every one of these forces a restart from scratch. That's not just wasted compute — it's wasted judgment. The agent's accumulated reasoning about *why* it made certain decisions, which tool calls already succeeded, and what the user already told it, all evaporates. Checkpointing is the mechanism that keeps that judgment alive across the interruption.

What a checkpoint actually captures

A checkpoint in LangGraph is a full snapshot of the graph's state at a specific point in its execution — not just the conversation history, but the entire state object as defined by your graph's schema. If your state includes messages, a scratchpad, a list of completed subtasks, retrieved documents, and a counter for how many times a tool has been retried, all of that is captured together, atomically, at each step.

This matters because agent state is rarely just messages: []. A real LangGraph state definition looks more like this:

from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    plan: list[str]
    completed_steps: list[str]
    retrieved_docs: list[dict]
    retry_count: int
    pending_approval: dict | None

Every field in this schema is part of what gets checkpointed. LangGraph checkpoints after each super-step — each round of node execution in the graph — so you get a checkpoint not just at the end of a run, but after every meaningful transition. This is the detail that separates LangGraph's model from a naive "save the conversation" approach: you're not persisting a chat log, you're persisting the entire working memory of a stateful process, including where in the graph's topology execution currently sits.

Each checkpoint is associated with metadata: a checkpoint ID, a parent checkpoint ID (forming a chain), the step number, and which node just ran. That parent-pointer structure is what makes time-travel possible later — checkpoints form a directed history, not just a single overwritten blob.

Checkpointer backends: picking the right one for the job

LangGraph separates the *concept* of checkpointing from the *storage* of checkpoints through a checkpointer interface. You compile your graph with a checkpointer, and the graph itself doesn't need to know or care where the bytes end up.

For local development and testing, an in-memory checkpointer is the default reach:

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph

checkpointer = InMemorySaver()

graph = StateGraph(AgentState)
# ... add_node(...), add_edge(...) calls ...
app = graph.compile(checkpointer=checkpointer)

This is fine for a notebook or a quick test, but it has an obvious limitation: the state lives in process memory. Restart the Python process and every checkpoint is gone. That's the opposite of what you want in production.

For anything that needs to survive a restart, or that needs to be shared across multiple server instances behind a load balancer, you need a durable backend. LangGraph ships checkpointer implementations backed by Postgres, SQLite, and Redis, and the same interface is implemented by LangGraph Platform's managed store if you're deploying there. Conceptually, swapping backends looks like this:

from langgraph.checkpoint.postgres import PostgresSaver

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup()  # creates tables on first run
    app = graph.compile(checkpointer=checkpointer)

The important architectural point is that this is a one-line swap at compile time. Your node functions, your graph topology, your business logic — none of it changes based on which checkpointer you use. That's the value of the abstraction: you develop against InMemorySaver and ship against PostgresSaver without touching the graph definition.

A production-grade checkpointer needs to handle a few things a naive implementation won't: concurrent writes from multiple graph instances, efficient serialization of arbitrary Python objects in your state (including things like Pydantic models or numpy arrays if you're doing anything numeric), and reasonably compact storage since checkpoints accumulate — every super-step writes a new row, not an overwrite. In high-throughput systems, checkpoint write volume is itself a capacity-planning concern, not an afterthought.

Threads: the unit of persistence

Checkpoints aren't just floating in a global pool — they're scoped to a thread, identified by a thread_id. A thread is LangGraph's unit of a single, resumable line of execution — think of it as a session ID. Every checkpoint written during a graph run is tagged with the thread_id that run belongs to, and when you invoke the graph again with that same thread_id, LangGraph loads the latest checkpoint for that thread and continues from there instead of starting a fresh state.

This is the mechanism that makes multi-user, multi-session persistence trivial to reason about. You don't build your own session store, your own "load the last N messages for this user" query, or your own resume logic. You pass a thread_id, and the checkpointer does the lookup.

config = {"configurable": {"thread_id": "user-4471-session-1"}}

result = app.invoke(
    {"messages": [{"role": "user", "content": "Draft a refund policy for our SaaS."}]},
    config=config,
)

If the process crashes right after this call, or if the user closes their laptop and comes back the next day, invoking again with the same config picks the conversation back up mid-stream:

config = {"configurable": {"thread_id": "user-4471-session-1"}}

result = app.invoke(
    {"messages": [{"role": "user", "content": "Actually, make it stricter on digital goods."}]},
    config=config,
)

Because state is loaded by thread_id, a single application instance can be serving thousands of concurrent users, each with their own independent, resumable graph state, without any cross-contamination. User A's retrieved_docs and plan fields never leak into user B's run, because they're keyed to entirely separate threads in the checkpoint store. This is the pattern behind any production chat product where a user can leave mid-conversation and return hours later to find the assistant "remembers" — the memory isn't a clever prompt trick, it's a checkpoint being reloaded by thread ID.

It's worth being precise about what a thread persists versus what true long-term memory persists. A thread is bounded, ongoing state for one line of execution — great for "resume this conversation" or "resume this task." If you want an agent to recall facts about a user across entirely separate threads — preferences learned three weeks ago in a different conversation — that's a different concern, usually solved by writing distilled facts out to a separate long-term store (a vector index, a key-value profile store) rather than relying on the checkpoint history itself. Checkpointing solves *process* persistence; cross-thread memory is a related but distinct problem layered on top.

Resuming after a crash or an interrupt

The resumption story is the same whether the graph stopped because the process died or because it deliberately paused for human input — and that uniformity is the point. LangGraph doesn't need a special "crash recovery mode" distinct from its "human-in-the-loop mode." Both are just: load the last checkpoint for this thread, and continue.

For deliberate pauses, you typically use an interrupt inside a node — the graph reaches a point, writes its checkpoint, and returns control to the caller without finishing:

from langgraph.types import interrupt

def request_approval(state: AgentState):
    decision = interrupt({
        "action": "send_refund_email",
        "amount": state["pending_approval"]["amount"],
    })
    return {"messages": [{"role": "system", "content": f"Approved: {decision}"}]}

The graph halts at that interrupt call. Nothing is lost — the entire state up to that point is already durably checkpointed. Later, potentially from a completely different process (a different server, a different day), you resume by invoking the same thread with a Command that supplies the human's decision:

from langgraph.types import Command

config = {"configurable": {"thread_id": "user-4471-session-1"}}

result = app.invoke(
    Command(resume={"approved": True}),
    config=config,
)

Notice there's no need to reconstruct the messages list, replay the plan, or re-fetch the documents the agent had already retrieved. The checkpointer already has all of that under thread_id. This is exactly the same code path you'd use to recover from an ungraceful crash — if the process had simply died instead of hitting an intentional interrupt, you'd invoke with the last known input (or an empty resume) against the same thread_id, and the graph would restart execution from its last completed super-step rather than from node zero. You get crash recovery for free by building on the same primitive that gives you human-in-the-loop workflows.

Time-travel debugging: replaying from an earlier state

Because checkpoints form a chained history rather than a single mutable slot, you can do something more interesting than resuming forward — you can rewind. LangGraph exposes the full checkpoint history for a thread, and you can pick any prior checkpoint and fork execution from there.

history = list(app.get_state_history(config))

for snapshot in history:
    print(snapshot.config["configurable"]["checkpoint_id"], snapshot.values.get("plan"))

This gives you a list of every super-step the graph has passed through for that thread, each with its own checkpoint ID and the full state at that moment. Say you find that three steps ago the agent chose a bad plan, and you want to see what would have happened with a different tool result at that point. You can invoke the graph again, targeting that earlier checkpoint:

earlier_checkpoint = history[3].config

new_result = app.invoke(
    {"messages": [{"role": "user", "content": "Try a different search query."}]},
    config=earlier_checkpoint,
)

Because you invoked against an older checkpoint rather than the latest one, LangGraph doesn't overwrite the original future — it creates a new branch in the checkpoint history. The original line of execution is still there if you need to compare against it. This is genuinely valuable for two very different audiences: developers debugging why an agent went off the rails (you can pinpoint the exact super-step where a bad decision was made and inspect the full state at that instant, rather than staring at a flattened log), and product teams doing systematic evaluation (replaying the same earlier state against different prompts or tool configurations to A/B test agent behavior deterministically, instead of re-running the entire task from the beginning each time).

This is also where checkpointing stops being purely an operations concern and becomes a development tool. Most people reach for checkpointing to survive crashes; fewer realize it also gives you a reproducible, inspectable execution trace for free, because the chain of checkpoints *is* the trace.

Designing state schemas that checkpoint well

Not every state schema is equally cheap or safe to checkpoint. A few practical rules matter once you're persisting on every super-step rather than just holding state in memory for a single run.

  • Keep large, re-fetchable data out of the checkpointed state when you can. If a node retrieves a 50-document RAG result, consider storing a reference (a query, a doc-ID list) rather than the full text of every document, especially if that data is cheap to re-fetch and expensive to serialize repeatedly.
  • Use reducers deliberately. Fields like messages use an add_messages reducer so that new writes append rather than overwrite. Get this wrong on a list-typed field, and every super-step can silently balloon your stored state size as history duplicates itself instead of merging cleanly.
  • Keep state JSON- or pickle-serializable. Whatever backend you choose still has to serialize your Python objects. Exotic objects — open file handles, database connections, unpicklable closures — do not belong in the state dict; store identifiers and rehydrate the live object inside the node instead.
  • Version your schema deliberately. Once you have production threads with months of checkpoint history, changing a TypedDict field name or type is a migration problem, not a code change. Plan for backward compatibility, or write a migration path for old checkpoints, before you rename fields.

None of this is exotic engineering — it's the same discipline you'd apply to any persisted data model. The difference is that with agent state, the schema tends to evolve fast during early development, so it's easy to accumulate checkpoint debt before you've thought about any of this.

Checkpointing in multi-agent and subgraph architectures

Things get more interesting once your graph isn't a single flat sequence of nodes but a graph of graphs — a supervisor delegating to specialized subgraphs, each potentially with its own internal loop. LangGraph checkpoints subgraphs independently but nests them under the parent thread's execution, so a crash mid-delegation doesn't force you to restart the supervisor's entire plan, only the in-flight subgraph task, once resumed.

This matters in practice for any system built around a planner/executor split, or a fleet of tool-specialist subagents coordinated by an orchestrator — patterns that are increasingly the default shape of serious agent systems rather than the exception. The checkpoint for the parent thread captures which subgraph invocation is in flight and what the supervisor's plan state looks like; the subgraph's own checkpoint captures its internal progress. Resuming the parent thread transparently resumes the correct subgraph state as well, without you having to manually stitch together two independent recovery paths.

The practical implication: as you decompose a single mega-agent into a coordinated set of smaller graphs — which you should, for the same reasons you'd decompose a monolith into services — checkpointing scales with that decomposition instead of forcing you to redesign persistence from scratch. That's a meaningfully different guarantee than most hand-rolled "save the conversation to a database" approaches provide, which tend to assume a single flat state object and break down the moment you introduce nested control flow.

Common mistakes that undermine checkpointing

A few patterns show up repeatedly once teams move from tutorial-scale graphs to production traffic:

  • Reusing one `thread_id` across unrelated tasks. If you don't generate a fresh thread_id per logical session, unrelated conversations bleed into one another's state, and your "resume" behavior starts resuming the wrong thing.
  • Treating the in-memory checkpointer as production-ready. It's extremely common to prototype against InMemorySaver, ship it, and discover during the first rolling deploy that every in-flight conversation was silently dropped.
  • Ignoring checkpoint growth. Every super-step is a new row. A chatty agent with a long-running loop and a database-backed checkpointer can accumulate a surprising amount of storage; have a retention or compaction policy before this becomes an incident.
  • Storing secrets or PII directly in checkpointed state without considering the backend's access controls. The checkpoint store now holds a full copy of everything that ever passed through your graph's state — treat it with the same access discipline you'd apply to your primary application database, because that's effectively what it is.
  • Assuming resumption replays side effects. LangGraph resumes graph *state*, not real-world side effects. If a node already sent an email before the crash, resuming won't un-send it — idempotency for tool calls with external side effects is still your responsibility to design.

Bringing it together

Checkpointing turns an agent from a fragile, single-shot script into a durable process that can survive crashes, pause for a human, serve many users concurrently, and be inspected after the fact. The core ideas are simple even though the implications run deep: every super-step writes a full state snapshot, checkpoints are scoped to a thread_id so resumption and multi-user isolation come for free, and because checkpoints chain into a history rather than overwrite each other, you get time-travel debugging as a natural side effect of the same mechanism that gives you crash recovery. The backend you choose — in-memory for iteration, Postgres or another durable store for anything real — is a one-line swap precisely because LangGraph separates the concept of a checkpoint from where it's stored.

If you're building anything beyond a demo, checkpointing isn't an optional hardening step you bolt on later — it's a design decision that shapes your state schema from day one. Once a single agent's persistence is solid, the next question is almost always what happens when you have many agents, many users, and months of accumulated history to manage efficiently — which is exactly the territory we cover in "Scaling Memory for AI Agents", the next piece in this series.