LangGraph add_conditional_edges Routing Failures
A router that returns a string LangGraph doesn't recognize, a mapping dict that's missing a key, or a graph where a branch is silently unreachable. These are the failure modes of conditional edges — and how to make yours deterministic.
By Sana K. · Last updated Aug 14, 2026 · LangGraph · Page #122
Literal, (2) making the mapping dict exhaustive over every possible return, and (3) always including an explicit fallback branch to END.Real error messages you'll see
ValueError: At 'grade_documents' branch, got unknown branch 'rewrite' expected one of ['generate', 'END']
at StateGraph.compile() / Pregel.invoke()
# The routing function returned "rewrite" but that string was never mapped to a node.
KeyError: 'is_relevant'
at route_docs(state) — the routing function tried to read state['is_relevant'] but the upstream node never set it. Guard with .get() or set defaults in your state schema.
RuntimeError: Graph invocation exceeded recursion limit (25) without completing.
Mostly likely a routing function is looping between two nodes with no termination condition. Use graph.get_graph().draw_mermaid() to visualize cycles, and always route to END on the terminal branch.
Two forms of add_conditional_edges
| Form | When to use | Gotcha |
|---|---|---|
add_conditional_edges(src, fn) | When fn returns a valid node name directly (or END). | Every return value must be an actual node — no aliases. |
add_conditional_edges(src, fn, {"a": "node_a", "b": "node_b"}) | When fn returns a short label ("a", "b") and you map it to a node. | The mapping dict must cover every possible return — missing keys = ValueError at compile. |
add_conditional_edges(src, fn, path_map={...}) | Same as above; path_map is the explicit kwarg for readability. | Prefer this form in LangGraph 0.2+ for clarity. |
Function returns list[str] | Parallel routing (fan-out) to multiple downstream nodes. | Each string must map to a node; downstream nodes run in parallel and their outputs merge. |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 28%Routing function returns a value not in the mapping. Function returns
"rewrite"but mapping is{"good": "generate", "bad": "END"}. Compile raisesValueError: unknown branch. - 21%Routing function returns a raw node name AND a mapping is passed. If you pass
path_map, the return must be a key of that map — not the actual node name. Drop the mapping or use short labels. - 17%Routing function reads a state key that doesn't exist yet.
state["classification"]raisesKeyErrorbecause the classifier node never set it (crashed, returned{}, or ran on a different branch). - 12%No branch routes to
END. Your graph loops forever until it hits the recursion limit (default 25). Every conditional edge should have at least one path that terminates. - 9%Mapping key doesn't match a real node.
{"good": "generator"}but the node is registered as"generate". Compile-time errorValueError: Node 'generator' not found. - 7%Router returns
Noneon some branch. Falls through anif/elifchain with noelse. Same fix as node returns — always return something explicit. - 6%Parallel branches produce conflicting state updates. Two nodes running in parallel both write to the same non-reduced state key. Second write wins, silently. Use a reducer for keys touched by parallel nodes.
How to fix it
Type the routing function with Literal — catch bad returns at import
Stops the ValueError before it happens.
A routing function is a pure function state -> str. If you type its return with Literal["a", "b", "c"], static analyzers refuse to accept any string outside that set. This turns "unknown branch" runtime errors into mypy errors at import time, which is where you want them.
from typing import Literal
from langgraph.graph import StateGraph, START, END
# The router returns a Literal — mypy/pyright will refuse any other string
def route_docs(state) -> Literal["generate", "rewrite", "end"]:
if not state["docs"]:
return "rewrite"
if state["confidence"] > 0.8:
return "generate"
return "end" # explicit — no implicit fallthrough
graph = StateGraph(State)
graph.add_node("retrieve", retrieve)
graph.add_node("generate", generate)
graph.add_node("rewrite", rewrite_query)
# path_map covers EVERY value in the Literal — nothing missing, nothing extra
graph.add_conditional_edges(
"retrieve",
route_docs,
path_map={
"generate": "generate",
"rewrite": "rewrite",
"end": END,
},
)
graph.add_edge("rewrite", "retrieve") # can loop back
graph.add_edge("generate", END)
graph.add_edge(START, "retrieve")
app = graph.compile()
type Route = Literal["generate", "rewrite", "end"] to name the alias — it becomes reusable across nodes.Guard every state read inside the router
Fixes the KeyError inside route_docs.
A routing function runs after the source node completes. If the upstream node returned {} or failed to set a key, the router will KeyError. Always use state.get(key, default) or ensure the schema sets a default. Either approach fails gracefully.
# ❌ Fragile — KeyError if upstream node didn't set 'classification'
def route_bad(state):
if state["classification"] == "math":
return "calculator"
return "chat"
# ✅ Defensive — .get() with an explicit default
def route_good(state):
kind = state.get("classification", "unknown")
if kind == "math":
return "calculator"
elif kind == "code":
return "sandbox"
else: # covers "chat", "unknown", None
return "chat"
# ✅ Even better — default in the schema, so state always has the key
from typing import Annotated, TypedDict, Literal
class State(TypedDict, total=False):
messages: list
classification: Literal["math", "code", "chat", "unknown"]
# Instantiate with defaults so downstream never sees missing keys
initial_state = {
"messages": [],
"classification": "unknown",
}
app.invoke(initial_state)
Always include an END branch in your router mapping
Prevents infinite loops that hit the recursion limit.
LangGraph enforces a default recursion limit (25 steps in current versions). A graph with only cyclic conditional edges — retrieve → grade → rewrite → retrieve → grade → rewrite → … — will hit the limit and raise RuntimeError mid-run. Every router should have at least one path to END, and every loop should have a bounded counter in state.
from typing import Annotated, TypedDict, Literal
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
messages: list
retry_count: int
is_good: bool
def grader(state) -> dict:
quality = evaluate(state["messages"])
return {"is_good": quality > 0.7}
def router(state) -> Literal["good", "retry", "give_up"]:
if state["is_good"]:
return "good"
if state["retry_count"] >= 3: # bounded loop — force termination
return "give_up"
return "retry"
def rewrite(state) -> dict:
new_msg = rewrite_query(state["messages"])
return {
"messages": [new_msg],
"retry_count": state["retry_count"] + 1, # increment on each loop
}
graph = StateGraph(State)
graph.add_node("retrieve", retrieve)
graph.add_node("grader", grader)
graph.add_node("rewrite", rewrite)
graph.add_node("generate", generate)
graph.add_edge(START, "retrieve")
graph.add_edge("retrieve", "grader")
graph.add_conditional_edges(
"grader",
router,
path_map={
"good": "generate",
"retry": "rewrite",
"give_up": END, # <-- explicit termination on failure
},
)
graph.add_edge("rewrite", "retrieve")
graph.add_edge("generate", END)
app = graph.compile()
# Initial state must set retry_count
result = app.invoke({"messages": [...], "retry_count": 0, "is_good": False})
app.invoke(state, {"recursion_limit": 50}). But treat that as a smell, not a feature — bounded loops are almost always safer.Prevention checklist
- Type every routing function's return value with
Literal[...]covering every possible outcome. - Make the
path_mapexhaustive — a key for everyLiteralvalue, and no extras. - Every branch of every router must return an explicit value — no implicit
Nonefrom unmatchedif/elif. - Use
state.get(key, default)inside routers to avoidKeyErrorwhen upstream nodes are inconsistent. - Include at least one path to
ENDin every conditional edge, especially in loops. - Track loop iterations in state (
retry_count,attempts) and terminate at a bounded threshold. - Run
graph.get_graph().draw_mermaid()in tests and eyeball it — unreachable nodes and hidden cycles show up visually.
Frequently asked questions
Yes. LangGraph accepts async routing functions as of 0.2. The router's return value must still be a string (or list[str] for fan-out), not a coroutine — that is, you can await inside the router, but you must return the branch value, not a promise of it. Async routers are useful when routing decisions require an LLM call or a DB lookup.
Without a path_map, the router's return string must be an actual registered node name. With a path_map, the return is a short label (like "good" or "bad") that the map translates to a node. Path_map is preferable when the routing labels are semantically meaningful independent of node names — for example, when the same router is reused across two different graphs with different node names.
When a router returns list[str], LangGraph runs those downstream nodes in parallel. If they all write to the same state key that doesn't have a reducer, the last write wins and earlier writes are lost. Fix: annotate that key with a reducer — Annotated[list, operator.add] for lists, or Annotated[list, add_messages] for messages — so parallel updates are merged instead of overwritten.
Yes. In LangGraph 0.2+, a node can return Command(update={...}, goto="next_node") and skip add_conditional_edges entirely. This is preferable when routing logic is tightly coupled to state computation — you avoid a separate router function. Conditional edges remain the right choice when the same source node has stable routing rules that are decoupled from the node's work.
Enable LangGraph tracing (LangSmith is free for individuals) and inspect the state snapshot at the router node. LangSmith shows exactly which state values fed into the router and what it returned. Alternatively, add a print(state.get("classification"), state.get("confidence")) at the top of the router — it's crude but instant. For production, log the router's decision to your observability stack alongside the state hash.