Debugging stateful workflows
Find and fix real graph bugs by drawing the graph, streaming state updates, reading checkpoint history, forking with time travel and setting deliberate recursion limits, with LangSmith and LangGraph Studio for heavier cases.
Words in this lessonStream mode · Checkpointer · Time travel · Recursion limit · Reducer · Trace · LangSmith · LangGraph Studio
- Stream mode
- A setting that chooses what you see after each step when you stream a graph run. Like choosing between watching the whole kitchen on CCTV or reading each station's note as it is written.
- Checkpointer
- The component that saves a snapshot of a graph's state after every step so a run can be inspected or resumed later. Like the auto-save in a video game, written to a memory card that any console can read.
- Time travel
- Going back to an earlier saved step of a graph run and continuing from there, optionally with changed state. Like rewinding a recorded cooking show to just before the cake went wrong and trying a different oven temperature from there.
- Recursion limit
- A cap on how many steps one run of a graph may take before it is stopped. Like a rule that any order bounced between stations more than ten times goes straight to the manager.
- Reducer
- A rule that says how a new value for a state key is combined with the old one. Like the difference between rubbing out the last note on a ticket and adding a new line under it.
- Trace
- A detailed record of everything one request did, step by step, including every model call and its timing. Like a parcel tracking history that lists every depot the parcel passed through and how long it waited at each.
- LangSmith
- A hosted service from the LangChain team that records and displays traces of LLM app runs. Like a flight recorder for your app that you can replay in a web browser.
- LangGraph Studio
- A visual debugger that draws your graph and lets you run it, watch its state and rewind it by clicking. Like a glass-walled kitchen where you can pause service and inspect every ticket on the rail.
On this page
The first evening of Diwali week, 7:40 pm. The WhatsApp line was taking a message every few seconds, and Meera forwarded Arjun a screenshot with no comment. A customer had asked, “Does the large Diwali hamper have anything with peanuts? Gifting it to my sister-in-law, she’s allergic.” Forty seconds later the bot had replied: “Sorry, something went wrong. A teammate will follow up.”
The server log had one useful line: GraphRecursionError. The graph had run out of steps.
Two days earlier, Arjun had made the allergy branch “safer”. After the branch drafted an answer, a new check node asked the model to verify that the draft quoted the recipe notes. If not, it sent the draft back with feedback for another try. It had worked perfectly on the brownie question. On the hamper question, it apparently never stopped.
Arjun did what most people do first. He added print statements to every node and ran it on his laptop with “Does the walnut brownie box have peanuts?” It worked. He tried the hamper question and got a different-looking loop from the one in production. Then he raised the step limit from 12 to 100 “to give it room”. The error came back anyway, after forty more model calls, about ₹27 in API spend for one customer message, and a minute and a half of waiting.
Priya stopped him before he raised it again. “You’re reading the smoke, not looking at the fire. Let’s find out what the graph actually did, step by step, with the exact state it had.”
Important
The idea in one sentence: because a LangGraph run is a sequence of nodes acting on an explicit state, you can debug it by watching that state change step by step (streaming), inspecting and rewinding saved snapshots of it (checkpoints and time travel), and fixing the structure (reducers, routers and loop limits) rather than guessing from the final error.
The flight recorder
“When a plane has a problem,” Priya said, “investigators don’t interview the plane. They pull the flight recorder, which logged every instrument reading, second by second. Then they replay the flight up to the moment it went wrong.”
“And we have one of those?”
“You have three. The graph’s own drawing, which shows every path that is possible. Streaming, which shows the path it is taking right now, live. And checkpoints, which record the full state after every step so you can rewind to any of them and try again from there.”
Where the analogy breaks: a flight recorder only records. A LangGraph checkpoint lets you change the recorded state and fly on from that point, which no investigator can do with a real plane. That is the superpower in this lesson.
Here is the simplified allergy branch Arjun had shipped, as a standalone graph you can run. Read it carefully; it contains two bugs.
# allergy_check.py
from typing import TypedDict
from dotenv import load_dotenv
from langchain_anthropic import ChatAnthropic
from langchain_core.output_parsers import StrOutputParser
from langgraph.graph import StateGraph, START, END
load_dotenv()
to_text = ChatAnthropic(model="claude-opus-5-5", max_tokens=2000) | StrOutputParser()
RECIPES = {
"walnut brownie box": "Walnut brownie box: flour, cocoa, butter, sugar, eggs, "
"walnuts. Shared equipment with peanuts and almonds.",
"plum cake": "Christmas plum cake: raisins, almonds, cashews, orange peel.",
}
class AllergyState(TypedDict):
question: str
notes: str
draft: str
verdict: str
feedback: list[str] # every critique so far... or so Arjun thought
def retrieve(state: AllergyState) -> dict:
hits = [text for name, text in RECIPES.items() if name in state["question"].lower()]
return {"notes": "\n".join(hits)}
def draft(state: AllergyState) -> dict:
fixes = "\n".join(state["feedback"]) or "none"
text = to_text.invoke(
f"Answer this allergy question using ONLY the notes.\nNotes:\n{state['notes']}\n"
f"Question: {state['question']}\nFix these problems from earlier drafts: {fixes}")
return {"draft": text}
def check(state: AllergyState) -> dict:
verdict = to_text.invoke(
"Does this answer quote the recipe notes to support its claim? Reply 'pass' "
f"or 'retry: <reason>'.\nNotes:\n{state['notes']}\nAnswer:\n{state['draft']}"
).strip().lower()
if verdict.startswith("pass"):
return {"verdict": "pass"}
return {"verdict": "retry", "feedback": [verdict]}
def after_check(state: AllergyState) -> str:
return "done" if state["verdict"] == "pass" else "retry"
builder = StateGraph(AllergyState)
builder.add_node("retrieve", retrieve)
builder.add_node("draft", draft)
builder.add_node("check", check)
builder.add_edge(START, "retrieve")
builder.add_edge("retrieve", "draft")
builder.add_edge("draft", "check")
builder.add_conditional_edges("check", after_check, {"done": END, "retry": "draft"})
graph = builder.compile()
Tool one: draw the graph
Before running anything, look at the shape. Every compiled graph can describe itself:
print(graph.get_graph().draw_mermaid())
That prints Mermaid text (a simple language for diagrams, which many Markdown viewers and GitHub render as a picture). Rendered, it looks like this:
flowchart LR
S([START]) --> R[retrieve]
R --> D[draft]
D --> C[check]
C -->|done| E([END])
C -->|retry| D
Priya put her finger on the check → draft arrow. “There’s a cycle. What’s the only way out of it?”
“The checker says pass.”
“And if it never does?”
Arjun had no answer. The drawing had shown the first bug before a single model call: a loop whose only exit depends on a model’s judgement. Nothing in the structure guarantees the loop ends.
Tool two: stream the run and watch the state
Next, watch it happen. graph.stream yields an item after every step, and the stream_mode setting chooses what each item contains:
stream_mode |
Each item is | Use it to |
|---|---|---|
"values" |
the full state after the step | see the whole ticket at every station |
"updates" |
{node_name: what_it_returned} |
see who changed what (start here) |
"messages" |
(token_chunk, metadata) pairs from LLM calls |
stream text to a user as it is written |
"debug" |
detailed events for each task and checkpoint | dig into ordering and timing |
You can also pass a list, like stream_mode=["updates", "values"], and each item then comes back as a (mode, data) pair.
from langgraph.errors import GraphRecursionError
q = "Does the large Diwali hamper have anything with peanuts? Gifting it to my sister-in-law."
start = {"question": q, "notes": "", "draft": "", "verdict": "", "feedback": []}
try:
for update in graph.stream(start, stream_mode="updates",
config={"recursion_limit": 12}):
print(update)
except GraphRecursionError:
print("STOPPED: hit the recursion limit")
Here is what came back (drafts shortened):
{'retrieve': {'notes': ''}}
{'draft': {'draft': "I don't have recipe information for the Diwali hamper, so I can't confirm..."}}
{'check': {'verdict': 'retry', 'feedback': ['retry: the answer does not quote any recipe note']}}
{'draft': {'draft': "I don't have recipe information for the Diwali hamper, so I can't confirm..."}}
{'check': {'verdict': 'retry', 'feedback': ['retry: no recipe notes are cited']}}
{'draft': {'draft': "I don't have recipe information for the Diwali hamper, so I can't confirm..."}}
...
STOPPED: hit the recursion limit
Read it like a detective. Three facts jump out.
The very first line: notes is empty. retrieve matches recipe names inside the question. “Diwali hamper” is not a recipe; it is a box of several products. So the drafter had nothing to quote, and honestly said so.
The checker can never pass an honest “I don’t know”. It was asked whether the answer quotes the notes. With empty notes, no answer can. The loop’s exit was unreachable for this question.
The drafts never change. The drafter is supposed to see all previous feedback and adjust. Yet each draft is word for word the same. Why?
Note
Pause and think. Look at AllergyState and at what check returns. Why does the drafter never see more than one piece of feedback, even after five rounds?
Show the answer
feedback: list[str] has no reducer. When check returns {"feedback": ["retry: ..."]}, LangGraph’s default rule replaces the old list with the new one-item list. So state["feedback"] only ever holds the latest critique, not the history. The fix is feedback: Annotated[list[str], operator.add], which appends instead. This is the silent “last writer wins” bug from the first lesson of this module, and it is one of the most common LangGraph bugs in real code.
Tool three: checkpoints, snapshots and history
Streaming showed the path. To rewind it, you need the state saved after every step. That is what a checkpointer does: after each step, it saves a snapshot of the full state, called a checkpoint, under a conversation ID called a thread ID. The next lesson is all about storing checkpoints durably in a database. For debugging, the in-memory one is enough:
from langgraph.checkpoint.memory import InMemorySaver
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "diwali-hamper-bug"}, "recursion_limit": 12}
try:
graph.invoke(start, config=config)
except GraphRecursionError:
pass
snapshot = graph.get_state(config) # the latest checkpoint
print(snapshot.next) # which node would run next
print(snapshot.values["feedback"])
print(snapshot.metadata["step"])
('draft',)
['retry: no recipe notes are cited']
11
A StateSnapshot has the parts you need: .values (the state), .next (the node or nodes due to run), .metadata (including the step number) and .config (the exact address of this checkpoint, which you will use to rewind).
get_state_history walks backwards through every checkpoint on the thread, newest first:
for snap in graph.get_state_history(config):
print(snap.metadata["step"], snap.next, repr(snap.values.get("verdict")))
11 ('draft',) 'retry'
10 ('check',) 'retry'
9 ('draft',) 'retry'
...
2 ('check',) ''
1 ('draft',) ''
0 ('retrieve',) ''
-1 ('__start__',) ''
(The exact step numbers can differ slightly between LangGraph versions. The pattern is what matters.)
This is the flight recorder, readable as a list. Notice that the error did not wipe it out: everything up to the last completed step was saved. In production, with the persistent checkpointer from the next lesson, you can open the customer’s actual thread the next morning and read it exactly like this, instead of trying to reproduce it on your laptop with a different question.
Tool four: time travel
Now the superpower. Every snapshot’s .config points to that exact moment. Two things you can do with it:
Replay. graph.invoke(None, config=snap.config) resumes from that checkpoint. Passing None as the input means “no new input; continue from here”. Steps before the checkpoint are not re-run; steps after it run again (and model calls are made again, so they may produce different text).
Fork. graph.update_state(snap.config, {...}) writes a change into the state at that point, as if a node had returned it, and gives you a new config. Invoking from the new config runs a new branch of history. The original stays intact.
Priya used the fork to test a hypothesis cheaply: if retrieval had found the hamper’s contents, would the rest of the graph have worked? She did not need to fix the retriever first to find out.
HAMPER_NOTES = ("Large Diwali hamper: kaju katli (cashews), almond cookies, walnut "
"brownie box (shared equipment with peanuts), plum cake (almonds, "
"cashews). Packed in a facility that handles peanuts.")
history = list(graph.get_state_history(config))
after_retrieve = [s for s in history if s.next == ("draft",)][-1] # the oldest one
fork_config = graph.update_state(after_retrieve.config, {"notes": HAMPER_NOTES})
result = graph.invoke(None, config=fork_config)
print(result["verdict"], "|", result["draft"][:120])
pass | Yes, please be careful. The large Diwali hamper includes a walnut brownie box made on
shared equipment with peanuts, and the whole hamper is packed in a facility that handles peanuts...
One draft, one check, a pass. Two model calls instead of re-running the whole thing from the top, and the hypothesis confirmed: the loop logic was fine when retrieval worked. The root cause was the retriever, and the loop’s real crime was having no way out when retrieval failed.
The [-1] is there because the history contains many checkpoints whose next node is draft, newest first; you want the oldest one, right after retrieve. On a long thread, print the history first and choose by eye. That is normal practice, not a hack.
Note
Pause and think. Arjun’s instinct was to raise recursion_limit from 12 to 100. Given what the stream and the history showed, would any limit have made this question succeed?
Show the answer
No. With empty notes, the checker could never return “pass”, and because of the missing reducer the drafter could not even learn from accumulated feedback. A higher limit only made the failure slower and more expensive: about forty more model calls and ₹27 for one message. The recursion limit is a safety net, not a fix. When you hit it, the question is always “why is there no exit?”, never “how do I allow more steps?”.
Fixing the structure
Three changes, each aimed at a cause the tools exposed:
import operator
from typing import Annotated
class AllergyState(TypedDict):
question: str
notes: str
draft: str
verdict: str
feedback: Annotated[list[str], operator.add] # 1. accumulate, don't overwrite
attempts: int
def draft(state: AllergyState) -> dict:
... # same as before
return {"draft": text, "attempts": state["attempts"] + 1}
MAX_ATTEMPTS = 2
def after_check(state: AllergyState) -> str:
if state["verdict"] == "pass":
return "done"
if state["attempts"] >= MAX_ATTEMPTS: # 2. a guaranteed exit
return "handoff"
return "retry"
def handoff(state: AllergyState) -> dict:
return {"draft": "I want to be careful with allergies, so I've asked our store "
"team to check this hamper and message you within the hour."}
builder.add_node("handoff", handoff)
builder.add_conditional_edges("check", after_check,
{"done": END, "retry": "draft", "handoff": "handoff"})
builder.add_edge("handoff", END)
And the third change, outside this file: the hamper’s contents were added to the recipe archive, and retrieval was switched to proper search (Module 09’s job). Even then, the handoff exit stays, because some future question will always find nothing.
Run the hamper question again. Streaming now shows retrieve, draft, check (retry), draft (with two feedback lines visible), check (retry), handoff. Six steps, four model calls, a safe answer in about eight seconds, and it would be the same with any recursion limit above six.
Keep a generous recursion_limit anyway, and catch GraphRecursionError at the top of your app so that if some other loop misbehaves, the customer gets a polite handoff, not a stack trace.
A field guide to common graph bugs
Priya made Arjun write this list on the team wiki. Each bug has a symptom you will recognise.
- Missing reducer. Symptom: a list that should grow only holds the latest item. Fix:
Annotated[list, operator.add], oradd_messagesfor chat. - Router returns a name not in the map. Symptom: the run crashes at a conditional edge with an error naming an unknown branch or node, often only for unusual inputs (“Allergies” instead of “allergy”). Fix: normalise the label, check it against a set, return a fallback.
- Mutating state in place. Symptom:
state["feedback"].append(x); return {}seems to work in a quick test, then values vanish, appear in the wrong checkpoint, or double up when a reducer also runs. Fix: never modifystate; always return the change as a new value. - Loops with a model-only exit. Symptom:
GraphRecursionError, often on rare inputs. Fix: a counter in state and a router that exits when it reaches a maximum. - Subgraph key mismatch. Symptom: a subgraph runs but behaves as if it got empty input. Fix: check that parent and subgraph share the keys you expect, or wrap the subgraph in a function node that maps keys explicitly.
Seeing more: LangSmith and LangGraph Studio
Printing streams is enough to find most bugs. Two tools from the LangChain team make it more comfortable at scale.
LangSmith is a hosted tracing service. A trace is a recorded tree of everything one run did: every node, every model call with its full prompt and reply, token counts and timings. Turning it on for LangChain and LangGraph code needs only environment variables in your .env:
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=your-key-here
Every run then appears in LangSmith’s web interface, where you can click into the exact prompt the check node sent on round four. Module 22 compares it with open alternatives like Langfuse. Remember that traces contain everything customers wrote, so treat them as personal data.
LangGraph Studio is a visual debugger for graphs: it draws your graph, lets you send inputs, watch state change node by node, inspect threads and fork from any checkpoint by clicking. You run it locally from the LangGraph command-line tool:
uv add "langgraph-cli[inmem]"
Add a langgraph.json in the project folder that says where your graph lives:
{
"dependencies": ["."],
"graphs": { "support": "./support_graph.py:graph" },
"env": ".env"
}
Then start the development server, which prints a link to open Studio in your browser:
uv run langgraph dev
"support" is the name Studio shows; ./support_graph.py:graph means “the variable graph in that file”. Studio supplies its own checkpointer in this mode, so export the graph compiled without one.
Library APIs change quickly. If a name has moved, check the docs linked below.
Try it yourself
Take the fixed allergy graph, compile it with InMemorySaver, and run the hamper question on thread "exercise-1". Then:
- Use
get_state_historyto find the checkpoint just before the firstcheckran. - Fork from it with
update_state, settingdraftto an answer that clearly quotes the hamper notes. - Invoke from the fork and confirm it ends after a single
check.
Show the answer
config = {"configurable": {"thread_id": "exercise-1"}}
graph.invoke({"question": q, "notes": "", "draft": "", "verdict": "",
"feedback": [], "attempts": 0}, config=config)
history = list(graph.get_state_history(config))
before_check = [s for s in history if s.next == ("check",)][-1] # oldest one
fork = graph.update_state(before_check.config,
{"draft": "Per our notes, the hamper includes a walnut brownie "
"box made on shared equipment with peanuts."})
print(graph.invoke(None, config=fork)["verdict"]) # pass
[-1] picks the oldest matching snapshot because history is newest first. If you see handoff instead of pass, your draft did not quote the notes clearly enough for the checker; that is the checker doing its job.
Common mistakes
- Raising the recursion limit to “fix” a loop. It only makes the failure slower and costlier. Find out why there is no exit.
- Debugging on a different input. Reproduce with the exact customer input, or better, read the real thread’s history from the checkpointer.
- Forgetting that replay re-runs model calls. Steps after the checkpoint run again and may produce different text. Fork with
update_statewhen you need a controlled experiment. - Logging only the final error. Stream
updatesin development and turn on tracing in production; the path is the evidence. - Leaving tracing on with no data policy. Traces hold full customer messages. Decide who can see them and how long you keep them.
Recap
- Draw the graph with
get_graph().draw_mermaid()to spot loops with no guaranteed exit. - Stream with
stream_mode="updates"to see each node’s change;"values"for full state,"messages"for tokens,"debug"for everything. - With a checkpointer,
get_stateandget_state_historylet you read every step of a real run. - Time travel: replay with
invoke(None, config=snapshot.config), or fork withupdate_stateand test a fix without rerunning everything. - Most bugs are structural: missing reducers, unvalidated routers, in-place mutation and loops without counters.
recursion_limitis the net, not the fix.
The next morning Arjun explained it to Meera without a single code term. “The bot had a proofreader that kept sending answers back. For the hamper, there was nothing to quote, so the proofreader could never be happy, and the writer couldn’t even see the earlier complaints. Now the writer sees all of them, and after two tries it hands over to the store team. And I didn’t have to guess: I rewound the conversation to the exact moment before it went wrong and tried the fix from there.”
“Rewound it?” said Meera. “Then why did that woman on WhatsApp yesterday have to repeat her whole order after you deployed the update?”
Arjun opened his mouth and closed it again. The rewinding worked because the checkpoints were in memory, on his laptop. On the server, every deploy wiped them. Priya, who had been listening, said: “That’s tomorrow. We’re going to put the flight recorder somewhere that survives a restart.”
Go deeper
- LangGraph documentation: the concepts pages on persistence, streaming and time travel, and the LangGraph Studio guide.
- LangChain documentation: tracing setup with LangSmith.
- Building effective agents (Anthropic): the evaluator-optimizer pattern behind this draft-and-check loop, and its pitfalls.