When your LangGraph agent hits langgraph.errors.GraphRecursionError: Recursion limit of 25 reached without hitting a stop condition, you're not dealing with a stack overflow. You've built a graph that has advanced—completed a full cycle from start to decision point back to a node—25 times without ever reaching the END state. This is almost always a bug in your graph's routing logic, not a performance issue requiring more memory. The error is a safety valve against infinite loops, and fixing it means understanding why your graph never exits. This guide walks through the mental model, common causes, and how to debug and prevent these errors from reaching production.
Unlike Python's RecursionError, which is a C-stack depth limit, GraphRecursionError counts super-steps: how many times the graph's state machine has cycled through nodes and made routing decisions. A conditional edge that checks "should we call the tool again?" but never routes to END will always exhaust this limit. Raising the limit is almost never the right fix—understanding what's looping is.
The Mental Model: Nodes, Edges, Super-Steps, and END
A LangGraph StateGraph is a state machine. You define nodes (functions that process state), edges (transitions between nodes), and conditional edges (functions that decide which node to route to based on state). Each time the graph transitions from one node to another, that's one super-step.
from langgraph.graph import StateGraph, END
from typing import TypedDict, Literal
class AgentState(TypedDict):
messages: list
call_count: int
def should_continue(state: AgentState) -> Literal["call_tool", "end"]:
if state["call_count"] > 5:
return "end" # Route to END
return "call_tool" # Back to the tool node
graph = StateGraph(AgentState)
graph.add_node("agent", agent_fn)
graph.add_node("tool", tool_fn)
graph.add_edge("agent", "router")
graph.add_conditional_edges(
"router",
should_continue,
{"call_tool": "tool", "end": END} # END stops execution
)
graph.add_edge("tool", "agent")
If should_continue never returns "end", the graph cycles forever: agent → router → tool → agent, consuming super-steps. When it hits 25, LangGraph stops and raises GraphRecursionError. The default limit of 25 is intentionally low to catch runaway loops fast.
Causes and Fixes
1. A Conditional Edge That Never Routes to END
This is the most common cause. Your router function checks a condition, but the condition is never true—or is true incorrectly.
Example (broken):
def should_continue(state: AgentState) -> Literal["continue", "end"]:
# Bug: always returns "continue"
if state.get("must_stop"):
return "end"
return "continue" # Oops—state never has "must_stop"
Fix: Verify your routing condition is reachable. Add logging to the router, or trace the state to see why it never takes the exit path.
def should_continue(state: AgentState) -> Literal["continue", "end"]:
has_complete_answer = state.get("complete", False)
max_steps_reached = state.get("step_count", 0) > 10
if has_complete_answer or max_steps_reached:
return "end"
return "continue"
The second condition is a hard safety net: no matter what, after 10 super-steps we exit.
2. The Agent Legitimately Needs More Super-Steps Than 25
Some graphs are deep—multi-stage validation pipelines, or agents that should genuinely call a tool 30 times. In this case, raise the limit deliberately.
Fix:
result = graph.invoke(
{"messages": input_messages},
config={"recursion_limit": 100}
)
But only do this after you've added a hard counter in state, so you can't accidentally spiral:
def check_max_iterations(state: AgentState) -> Literal["continue", "end"]:
step_count = state.get("step_count", 0) + 1
state["step_count"] = step_count
if step_count >= 50:
return "end" # Never go past 50, even if logic allows it
# ... rest of routing logic
return "continue"
3. State Not Being Updated the Way You Think
A reducer is overwriting state instead of appending, so the exit condition never sees progress. For example, if you're collecting tool outputs but the reducer keeps replacing them instead of growing a list:
Broken:
def update_messages(state: AgentState, output: dict):
return {"messages": [output]} # Replaces instead of appending!
Fixed:
def update_messages(state: AgentState, output: dict):
return {"messages": state.get("messages", []) + [output]}
Add a debug reducer that logs state transitions:
def log_step(state: AgentState) -> None:
step = state.get("step_count", 0)
call_count = len(state.get("messages", []))
print(f"Step {step}: {call_count} messages in state")
4. A Tool That Always Errors, Causing Retry Cycles
If your tool node catches exceptions and always re-routes back to the tool (instead of routing to END or a fallback), you'll loop forever.
Broken:
def tool_node(state: AgentState):
try:
return call_external_api(state["query"])
except Exception:
# Oops: no fallback, just keep trying
return {"retry": True} # Router sees this and goes back to tool
Fixed:
def tool_node(state: AgentState):
attempt = state.get("attempt", 0) + 1
state["attempt"] = attempt
try:
return call_external_api(state["query"])
except Exception as e:
if attempt < 3:
return {"error": str(e), "attempt": attempt}
else:
# Give up after 3 tries
return {"error": str(e), "fallback_response": "Service unavailable", "attempt": attempt}
Then route based on whether a fallback is set:
def route_after_tool(state: AgentState) -> Literal["retry", "end"]:
if state.get("fallback_response") or state.get("attempt", 0) >= 3:
return "end"
if state.get("error"):
return "retry"
return "end"
5. Fan-Out/Parallel Branches Consuming Super-Steps Faster Than Expected
If your graph sends work to multiple nodes in parallel (within a single super-step), you might think you're making progress, but the exit condition isn't checking the aggregated result.
Example: You fan out to 10 tool nodes in parallel, then aggregate results. That's 1 super-step for the fan-out, 1 for aggregation. If the aggregation logic says "call tools again," you're at step 2 already. Add a step counter to catch this:
def aggregate(state: AgentState) -> dict:
step_count = state.get("step_count", 0) + 1
return {"step_count": step_count}
def should_stop(state: AgentState) -> Literal["tools", "end"]:
if state.get("step_count", 0) > 3:
return "end" # After 3 aggregations, stop
return "tools"
How to Debug: Trace Your Steps
Stream the graph execution to see which node repeats:
for event in graph.stream({"messages": input_messages}):
node_name = list(event.keys())[0] # Which node just ran
print(f"Executed: {node_name}")
If you see the same node repeatedly, that's your loop. Then add logging inside that node and the router feeding it to see why the exit condition isn't triggered. This is the core of debugging production errors—observe the actual execution path, not what you think should happen.
For detailed step tracing, use LangGraph's built-in streaming:
step = 0
for event in graph.stream(
input_dict,
config={"recursion_limit": 50} # Catch the error sooner
):
step += 1
print(f"Step {step}: {event}")
if step > 30:
print("Still looping—exiting manually")
break
This gives you a concrete list of node executions to read.
In Production: A Runaway-Cost Bug
Every super-step usually means at least one LLM call. A loop of 100 super-steps that hits the limit is also 100+ LLM calls wasted, plus the error response back to your user. This is a runaway-cost bug as much as it is a logic bug. Monitoring for this error is as important as error tracking for any other Python exception.
Set up error alerting so you're notified the moment GraphRecursionError starts appearing in production. LightTrace captures the full stack trace, state context via breadcrumbs, and the affected endpoint—so you can spot patterns like "only happens on Friday when traffic spikes" or "always on this specific user ID." With the Sentry Python SDK, just point it at LightTrace and every exception—including GraphRecursionError—lands in your dashboard for Python error tracking.
When a GraphRecursionError arrives, read the stack trace to identify the last node before the error. That's your loop. Then add a debug version of the graph with lower recursion_limit and extra logging to replay the exact scenario. This kind of error-tracking best practice—capture context, alert fast, debug from production data—is what turns a silent cost drain into a quick fix.
Avoid the trap of raising recursion_limit as a band-aid. It trades a loud error (graph stops fast) for a silent cost drain (LLM calls and latency pile up). Fix the routing logic instead.
LangGraph counts super-steps, not function calls. If nodes A and B run in parallel in a single cycle, that's one super-step, not two. This is why your loop might feel "faster" than the step count implies.
Start tracking errors in minutes
Catch GraphRecursionError and other LLM-app bugs before they exhaust your API budget. LightTrace tracks every Python exception with full stack traces and state context—set alerts so you see runaway loops the moment they appear in production.