LangGraph Time Travel Debugging: Replaying Past Agent Decisions
Why Debugging Agents Feels Like Debugging Ghosts
Your agent made a bad tool call three steps ago, and now the final answer is garbage. You add a print statement, rerun the whole graph from scratch, and hope you get lucky enough to reproduce the bug. Except now the LLM samples a different token, takes a different branch, and the bug vanishes — or worse, a new one shows up. This is the reality of debugging non-deterministic, multi-step agents: the bug you're chasing might not exist anymore by the time you've added your logging.
Traditional debugging assumes you can re-run a program and get the same result. Agentic workflows break that assumption constantly. An LLM call is not a pure function. A tool might return different data on a second call. A human-in-the-loop step might get approved differently the second time around. If you can't reliably reproduce a bug, you can't fix it with print statements and re-runs.
This is exactly the problem LangGraph's time travel debugging solves. Because LangGraph persists a checkpoint of the entire graph state after every super-step, you don't need to reproduce anything. You can reach directly into the graph's execution history, pull out the exact state that existed right before the bad decision was made, inspect it, edit it, and resume execution from that precise point — without touching the steps that came before it. It turns debugging agents from "hope I can reproduce this" into "let me look at exactly what happened."
In this article we'll go deep into how time travel actually works under the hood, walk through get_state_history() and update_state() with real code, cover forking timelines to test alternate decisions, and lay out a practical debugging workflow you can use on your own LangGraph agents starting today.
The Checkpointer: What Makes Time Travel Possible
Time travel isn't a bolted-on feature — it falls directly out of how LangGraph executes graphs. Every LangGraph graph, once compiled with a checkpointer, writes a snapshot of its state after each "super-step" (a round of node execution). Each snapshot is tied to a thread_id and gets its own checkpoint_id. Together they form an append-only history of everything that happened in that conversation or run.
This means a LangGraph checkpointer is not just "save progress so I can resume after a crash." It's a full audit log of state transitions. Every checkpoint records:
- The complete graph state at that point (all channel values)
- Which node just ran and what it wrote
- Metadata: step number, source (
input,loop,update), and any tags you attach - A pointer to the parent checkpoint, forming a linked history
Setting this up is the same as any persistent LangGraph agent — you compile the graph with a checkpointer:
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from typing import TypedDict, Annotated
import operator
class AgentState(TypedDict):
messages: Annotated[list, operator.add]
plan: str
tool_results: Annotated[list, operator.add]
def planner(state: AgentState):
plan = f"Plan based on {len(state['messages'])} messages"
return {"plan": plan}
def executor(state: AgentState):
result = f"Executed: {state['plan']}"
return {"tool_results": [result]}
def critic(state: AgentState):
return {"messages": [{"role": "assistant", "content": "Reviewed and approved."}]}
builder = StateGraph(AgentState)
builder.add_node("planner", planner)
builder.add_node("executor", executor)
builder.add_node("critic", critic)
builder.add_edge(START, "planner")
builder.add_edge("planner", "executor")
builder.add_edge("executor", "critic")
builder.add_edge("critic", END)
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)For local debugging, MemorySaver is fine. For anything running in production, you'll want SqliteSaver or PostgresSaver so the history survives process restarts — and so you can pull up a user's session history days later when they report a weird result. The important part for time travel is that any of these checkpointers exposes the same history API, so everything below works identically regardless of backend.
Running the Graph and Capturing a Thread
Time travel only works on threads that already have history, so let's generate one. Every invocation needs a thread_id in the config — that's the key LangGraph uses to group checkpoints together.
config = {"configurable": {"thread_id": "debug-session-1"}}
result = graph.invoke(
{"messages": [{"role": "user", "content": "Summarize Q3 sales"}]},
config=config,
)
print(result["plan"])
print(result["tool_results"])Under the hood, this single invoke() call actually produced four checkpoints: one for the initial input, and one after each of planner, executor, and critic finishes. Nothing about this code looks different from a normal LangGraph agent — that's the point. Time travel isn't something you opt into by writing special code during execution. It's something you get for free because you used a checkpointer, and it becomes available the moment you want to inspect what happened.
get_state_history(): Walking Back Through Every Decision
This is the core primitive for time travel. get_state_history() returns every checkpoint recorded for a thread, most recent first, as an iterator of StateSnapshot objects.
history = list(graph.get_state_history(config))
for snapshot in history:
print("---")
print("Checkpoint ID:", snapshot.config["configurable"]["checkpoint_id"])
print("Next node(s):", snapshot.next)
print("Step:", snapshot.metadata.get("step"))
print("Values:", snapshot.values)Each StateSnapshot gives you:
values— the full state dict at that point in timenext— which node(s) will run next (empty tuple means the graph finished)config— contains thecheckpoint_id, which you'll use to jump back to this exact pointmetadata— step number, the writes that happened, and the source of the checkpointparent_config— the config of the checkpoint immediately before this one
This is what makes debugging an agent tractable. Instead of guessing, you can literally print the plan the agent produced right after the planner node ran, before executor or critic touched anything:
for snapshot in history:
if snapshot.metadata.get("source") == "loop" and "planner" in str(snapshot.metadata.get("writes", {})):
print("State right after planner ran:")
print(snapshot.values["plan"])
breakIf your bug is "the executor did the wrong thing," this history tells you immediately whether the problem originated in the plan itself, or whether the plan was fine and the executor misinterpreted it. That distinction alone saves hours versus staring at final output and guessing which node is at fault.
You can also fetch a single snapshot instead of the whole history, which is handy once you know which checkpoint you care about:
specific_state = graph.get_state(config)
print(specific_state.values)
print(specific_state.next)get_state() without a checkpoint_id gives you the latest snapshot. To look at an earlier one, pass its checkpoint_id explicitly.
Rewinding: Replaying From a Past Checkpoint
Once you've identified the checkpoint right before things went wrong, you can replay execution from there. This is the "time travel" part in the literal sense — you point the graph back at an old checkpoint and invoke it again.
history = list(graph.get_state_history(config))
# Find the checkpoint right after the planner node ran
target_snapshot = None
for snapshot in history:
if snapshot.next == ("executor",):
target_snapshot = snapshot
break
replay_config = target_snapshot.config
# Re-run from that exact point, with the exact same state
replayed_result = graph.invoke(None, config=replay_config)
print(replayed_result)Passing None as the input tells LangGraph "don't add new input, just resume from the state stored at this checkpoint." Execution picks up at whatever node is in snapshot.next — in this case, executor — using the state exactly as it existed at that checkpoint. Everything that happened *before* that point is untouched; you're not replaying the planner, you're replaying only what comes after.
This alone is powerful: if executor is nondeterministic (say, it calls an LLM with some temperature), you can replay from the identical pre-executor state multiple times and see how much the output varies. That tells you whether your bug is a logic bug (broken every time) or a sampling variance bug (broken sometimes).
update_state(): Editing History Before You Replay
Replaying the exact same state is useful, but the real power of time travel is combining it with editing the state before you resume. update_state() lets you take a past checkpoint, patch specific fields, and create a *new* checkpoint branching off from that point — without mutating the original history.
# Suppose we discovered the plan itself was bad. Let's fix it and re-run
# from the executor node with a corrected plan.
corrected_config = graph.update_state(
target_snapshot.config,
{"plan": "Plan: pull Q3 sales from CRM, filter by region, summarize top 5 deals"},
)
print("New checkpoint created:", corrected_config["configurable"]["checkpoint_id"])
fixed_result = graph.invoke(None, config=corrected_config)
print(fixed_result["tool_results"])A few things worth understanding about what just happened:
update_state()takes a config pointing at an existing checkpoint, plus a partial state dict.- It applies your patch the same way a node's return value would be applied — through your reducers (
operator.addfor list fields will append, not overwrite; plain fields get replaced). - It writes a new checkpoint as a child of the one you passed in, and returns a new config pointing at it.
- The original checkpoint (and everything downstream of it in the old run) is untouched. You now have two branches sharing the same history up to that point and diverging afterward.
This is what makes LangGraph's approach to time travel genuinely useful for debugging rather than just observability: you're not only looking at the past, you're able to construct a counterfactual — "what would have happened if the plan had been correct at this step?" — and actually execute it.
You can also target update_state() at a specific node, which is useful when a checkpoint sits between two nodes and you want to control which one's "turn" it is next:
graph.update_state(
target_snapshot.config,
{"plan": "Corrected plan text"},
as_node="planner",
)Passing as_node="planner" tells LangGraph to treat this update as if the planner node itself had produced it, which affects which node runs next according to your graph's edges.
Forking Timelines to A/B Test Agent Decisions
Because every update_state() call creates a new checkpoint rather than overwriting the old one, you can fork the same thread multiple times and compare outcomes side by side. This is enormously useful when you're trying to figure out *why* an agent chose one path over another, or when you want to test a fix against several plausible corrections.
base_config = target_snapshot.config
variants = [
"Plan: summarize Q3 sales by region",
"Plan: summarize Q3 sales by product line",
"Plan: summarize Q3 sales, flag anomalies only",
]
outcomes = []
for plan_text in variants:
branch_config = graph.update_state(base_config, {"plan": plan_text})
branch_result = graph.invoke(None, config=branch_config)
outcomes.append((plan_text, branch_result["tool_results"]))
for plan_text, tool_results in outcomes:
print("Plan:", plan_text)
print("Result:", tool_results)
print()Each of these branches shares identical history up through the planner checkpoint and diverges only afterward. If you're inspecting checkpoints in a persistent store (SQLite or Postgres), all three branches remain queryable later — you can pull up get_state_history() again and you'll see the tree structure, with parent_config letting you reconstruct which branch came from which point.
This pattern is also the backbone of building a "what if" debugging tool or an evaluation harness: instead of re-running your entire agent pipeline from scratch for every test variant, you replay only the part that changes, which is both faster and a more faithful test of the actual decision point you care about.
Practical Workflow: Debugging a Broken Agent Run Step by Step
Let's put this together into the workflow you'd actually use when a user reports "the agent gave a wrong answer." Assume you have the thread_id from logs or from your application's session tracking.
config = {"configurable": {"thread_id": "user-reported-bug-482"}}
# Step 1: Pull the full history for context
history = list(graph.get_state_history(config))
print(f"Found {len(history)} checkpoints for this thread")
# Step 2: Walk backward from the final state, looking for the first
# checkpoint where something looks wrong
for snapshot in history:
step = snapshot.metadata.get("step")
writes = snapshot.metadata.get("writes")
print(f"step={step} next={snapshot.next} writes={writes}")
# Step 3: Once you've spotted the offending checkpoint, inspect it fully
suspect = history[3] # example index found from step 2
print(suspect.values)
# Step 4: Try replaying it unmodified to check for nondeterminism
graph.invoke(None, config=suspect.config)
# Step 5: If the bug reproduces consistently, patch the state and confirm the fix
fixed_config = graph.update_state(suspect.config, {"plan": "corrected plan"})
graph.invoke(None, config=fixed_config)A few practical notes from applying this on real agents:
- Log `thread_id` values in production. Time travel is only as good as your ability to find the right thread. Tag every session with a stable, searchable
thread_id(a user ID plus timestamp, or a request ID) so you can pull it up later. - Use a durable checkpointer in anything user-facing.
MemorySaverdisappears when the process restarts.PostgresSaverorSqliteSaverkeep history around for as long as you need it, which matters when a bug report comes in a day after the run happened. - Checkpoint metadata is your map. Before you dive into
values, scanmetadata["writes"]andsnapshot.nextacross the whole history — it's a compact table of contents telling you exactly which node changed what, at every step, without you having to diff full state dicts by eye. - Don't try to "fix" state you don't fully understand.
update_state()applies your patch through the same reducers as normal execution. If a field usesoperator.add, patching it with a list appends rather than replaces — a common source of confusing debugging sessions.
Time Travel vs. Tracing Tools
It's worth being clear about where time travel fits relative to tools like LangSmith tracing. Tracing gives you an observability view — a timeline of spans, latencies, inputs and outputs, great for understanding what happened and how long it took. Time travel gives you executability — the ability to actually re-run the agent from any point in that history, with or without modification.
They're complementary rather than competing. In practice, a good debugging loop looks like: use tracing to spot *where* in a run something went wrong, then use get_state_history() and update_state() to zoom into that exact checkpoint and either reproduce it, patch it, or fork it into an experiment. Tracing tells you where to look; time travel lets you actually touch it.
Common Pitfalls When Working With Checkpoints
A handful of mistakes come up repeatedly when engineers first start using this API:
- Forgetting `thread_id` consistency. If you invoke the graph without a
thread_id, or with a different one than before, you're not continuing history — you're starting a brand-new thread.get_state_history()on the new thread will look almost empty. - Assuming checkpoints are mutable. They aren't.
update_state()never rewrites an existing checkpoint; it always creates a new one. If you want to discard a branch, you simply stop invoking it — the old thread history is unaffected and still queryable. - Confusing `as_node` targeting. If you don't pass
as_node, LangGraph infers which node "produced" your update based on the graph's structure, which can send execution down an edge you didn't intend. Be explicit when the graph has branching logic after the checkpoint you're editing. - Reading `next` incorrectly. An empty
nexttuple means the graph has already completed at that checkpoint — there's nothing left to replay forward from it. If you want to resume execution, pick a checkpoint wherenextis non-empty. - Not accounting for reducers when patching. As mentioned above,
Annotated[list, operator.add]fields append on update rather than overwrite. If you want to fully replace a list field during time travel, either use a reducer that supports replacement or clear the list explicitly.
Closing Thoughts
Nondeterministic, multi-step agents break the classic "reproduce the bug, add a print, rerun" debugging loop. LangGraph's answer is to make every intermediate state a durable, addressable object: get_state_history() gives you the full timeline, get_state() gives you a single frozen frame, and update_state() lets you branch off any point in that timeline with a corrected or experimental state. Instead of guessing why an agent went wrong, you can walk directly to the checkpoint where it happened, inspect the exact values in play, and test a fix by replaying forward — all without re-running the parts of the graph that were never the problem.
This capability becomes essential once you move past toy demos into agents that plan, call tools, and loop over multiple steps, because that's exactly where bugs stop being visible from the final output alone. If you want a structured, hands-on walkthrough of building and debugging graphs like this — covering checkpointers, human-in-the-loop interrupts, and time travel together — it's covered in depth in our LangGraph Tutorial course on teachyou.ai, where we build and debug a full multi-node agent from scratch.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.