Production failures

langgraph recursion limit of 25

Reproduction script · docs/measurements/langgraph-recursion-repro.py · updated 2026-08-05

If you are reading that LangGraph's recursion limit defaults to 25, that number is stale. Measured on langgraph 1.2.10, the default is 10007, and create_agent in langchain 1.3.14 sets 9999. That inversion matters more than the error itself: on a current version your loop guardrail is effectively gone, so a cycling agent no longer crashes early — it bills you first.

The symptom

The exception, verbatim from a run of the artifact on this page:

langgraph.errors.GraphRecursionError: Recursion limit of 10007 reached without
hitting a stop condition. You can increase the limit by setting the
`recursion_limit` config key.
For troubleshooting, visit: https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT

GraphRecursionError is imported from langgraph.errors. The same string appears in the JavaScript port and in downstream projects that embed LangGraph, which is why searching it returns hits from repositories you have never used.

The number in the message is the diagnostic. Read it before anything else, because it tells you which layer stopped the run:

What you seeWhat it means
10007LangGraph's own default on 1.2.x. Nothing in your stack set a limit
9999create_agent set it. That is its built-in value
25Something explicitly set 25, or you are on an old version
the value you setGood, your setting applied
a value you did not setSomething downstream overrode you

Where 10007 comes from is worth knowing, because it is also the cheapest lever in this whole page. In langgraph/_internal/_config.py:

DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007"))

An environment variable sets the floor for every graph in the process, with no call-site changes. Verified: with LANGGRAPH_DEFAULT_RECURSION_LIMIT=40, a graph that sets nothing dies at 40.

The famous 25 is a docstring that never got updated. In langchain_core/runnables/config.py, the recursion_limit field is still documented as "Maximum number of times a call can recurse. If not provided, defaults to 25." That line is what people quote in forum answers, and it no longer describes any runtime default. If you are chasing a 25 today, you are chasing something that was set on purpose somewhere in your stack — find it rather than raise it.

One more thing the message hides: the word recursion is misleading. Nothing recurses in the Python sense. The counter increments per super-step — one pass through the graph's active nodes — not per tool call and not per model call. A two-node cycle burns the budget twice as fast as it reads. In one production report a subagent doing 10 model calls and 15 tool calls hit its ceiling at exactly 25 operations, because those were 25 super-steps.

What causes it

An exit condition no tool output can satisfy. The most common shape by far. A conditional edge routes back to the worker unless a field is set, and the field is set from a tool that returns a subtly wrong value: an empty list instead of null, the string "false" instead of a boolean, a dict whose key was renamed by a provider update. The graph does exactly what you told it; the exit is simply unreachable. This is the case the artifact reproduces, deliberately without an LLM, so you can watch the mechanism with nothing else moving.

A model retrying the same failed call. The tool errors, the model reads it as transient, and tries again with near-identical arguments. Nothing in the default loop notices that state has not advanced. From outside it looks like the agent working; on the token ledger it is a leak.

Fan-out multiplying steps. Parallel branches and subgraph invocations draw from the same budget. A graph that terminates comfortably on one input can exceed the ceiling on an input that fans out to six branches, which makes the failure look intermittent and input-dependent rather than structural.

A limit you set that never applied. The one that wastes the most time, because the operator is certain the setting is in place. Three distinct mechanisms, and the artifact proves the first.

It is nested in the wrong key. recursion_limit is a standalone config key; the documentation is explicit that it "should not be passed inside the configurable key as all other user-defined configuration." Nest it and it is dropped with no warning. In the artifact, case C passes {"configurable": {"recursion_limit": 500}} and dies at 10007 — the same number as setting nothing at all. That identical number is your tell.

It is overridden downstream. A caller can send config that wins over yours. One reported case set the limit on the agent object and still saw 25, because the calling SDK sent its own assistant-level config; the fix was on the caller, not the agent. If you invoke through any hosted runtime or front-end SDK, assume the caller wins until proven otherwise.

It is not propagated across a nesting boundary. A parent configured at 300 had its subagents fall back to the default because the subagent invocation passed no config at all — await subagent.ainvoke(subagent_state). Worse, the subagent's death surfaced upstream as a cancelled task rather than a recursion error, which sends you looking in the wrong place entirely. That specific bug is fixed, but the pattern is general: every boundary is a place config can be dropped, and the symptom mutates as it travels up.

Telling it apart

Before changing any number, answer one question: has the state advanced?

A cycling graph revisits the same node with materially the same state. A legitimately long task revisits it with state that grows or changes. That is cheap to observe, because the step counter is available inside any node:

def my_node(state, config):
    step = config["metadata"]["langgraph_step"]
    ...

Log the step number next to a fingerprint of the fields your exit condition reads. Identical fingerprint across three consecutive visits means a loop, and no ceiling will fix it. A fingerprint that changes every pass means a long task, and the ceiling is genuinely too low.

LangGraph also exposes a RemainingSteps managed value, so a node can see how much budget is left and degrade on purpose — return the best partial answer, route to a summarisation node — instead of dying at the boundary. For anything user-facing that beats an exception.

One trap while diagnosing: the limit is not always enforced the way you expect. A report against langgraph==0.5.3 with langgraph-prebuilt==0.5.2 shows a prebuilt agent given a limit of 8 stopping silently after 3 tool calls and raising nothing, apparently when the last message in state is a tool message. If your agent stops early and quiet, do not assume the ceiling caused it — check whether anything was raised at all.

The fix

The order below is deliberate, and it is close to the opposite of the usual advice.

Set a real ceiling first. On a current version you are running with an effectively unbounded loop budget. 10007 super-steps of a graph that reads 40k tokens per pass is an invoice, not a guardrail. Pick a number that reflects your worst legitimate task and enforce it process-wide:

LANGGRAPH_DEFAULT_RECURSION_LIMIT=40 python your_app.py

That one variable gives you back the safety net the upgrade removed, without touching a single call site. If you cannot state the worst-case token cost of your current ceiling, you do not have a ceiling.

Make the exit condition provably reachable. Assert on the shape your router reads at the point the tool returns, not at the point the router runs. A tool that can return three shapes will eventually return the one your condition cannot match.

Break the retry cycle explicitly. Keep a counter, or a hash of the last tool call, in state, and route to a failure path when it repeats. The default loop has no memory of non-progress; you have to give it one. This is the only fix that bounds cost rather than deferring it.

Then, and only then, set a per-call limit where you need more room. As a default on the runnable, in dict form:

agent = create_agent(model=model, tools=tools).with_config({"recursion_limit": 50})

Or per invocation, as a standalone key:

graph.invoke(inputs, config={"recursion_limit": 50})

Two operational notes. Setting the limit when you run through the CLI or a hosted runtime is not covered by the documentation at the time of writing; the open request is in the sources. And every nesting boundary needs its own check: parent, subgraph, subagent. Propagation is not automatic.

Verifying the fix

Lower the limit to 2 and confirm the exception fires. This proves the setting is wired. It is the step almost everyone skips, and it is the reason most "I already set it" reports exist. If 2 does not raise, raising to 500 would have changed nothing.

Run the artifact next to your own graph. It needs no model, no key and no network, so it separates a mechanism problem from a provider problem in seconds. Check that its case A and case C print the same number — if they do on your version too, the nested-key trap is live in your stack.

Then log the step count and the exit-condition fingerprint in production for a week. If the maximum step count per run drifts upward while your inputs stay the same, you have a slow leak that will find whatever ceiling you set. The number to watch is not the limit; it is the distance between your typical run and it.

Sources

Every factual claim here comes from either a run of the artifact or a reported case listed in the page's sources field: the original recursion-limit issue and its downstream duplicates, the prebuilt agent that fails to raise, the JavaScript port where the config is ignored, the open documentation request for CLI usage, the subagent propagation bug with its production impact, the two forum threads on the current agent factory, and the LangGraph error and Graph API references for the standalone-key rule and the step counter.

The default values and the environment-variable behaviour were measured on langgraph 1.2.10 and langchain 1.3.14 on 2026-08-05, by reading DEFAULT_RECURSION_LIMIT in langgraph/_internal/_config.py, the create_agent config in langchain/agents/factory.py, and by running the artifact. These numbers are version-dependent and will move. The artifact prints your own values, which is why it exists.

Reported cases

Hit this in production?

Leave your email and we come back to you.