LangGraph Conditional Edge Routing Failures — Fix Guide (2026)
Graph Construction · Conditional Edges Severity: High

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.

TL;DRA conditional-edge failure means your routing function returned a value that isn't in the mapping dict (or isn't a valid node name). Fix it by (1) typing the return with 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 from unknown branch
ValueError from unknown branch
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 inside routing function
KeyError inside routing function
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.
Silent hang — no next node fires
Silent hang — no next node fires
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

FormWhen to useGotcha
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 raises ValueError: 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"] raises KeyError because 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 error ValueError: Node 'generator' not found.
  • 7%
    Router returns None on some branch. Falls through an if/elif chain with no else. 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

Fix #1

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.

typed_router.pypython
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()
Note: If you upgrade to Python 3.12+, use type Route = Literal["generate", "rewrite", "end"] to name the alias — it becomes reusable across nodes.
Fix #2

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.

defensive_router.pypython
# ❌ 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)
Note: A missing state key inside a router is almost always a symptom of an upstream node not returning that key on every code path. Fix it there too — otherwise the same bug will show up in the next router downstream.
Fix #3

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.

bounded_loop.pypython
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})
Note: If you truly need a longer loop, raise the recursion limit at invocation: 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_map exhaustive — a key for every Literal value, and no extras.
  • Every branch of every router must return an explicit value — no implicit None from unmatched if/elif.
  • Use state.get(key, default) inside routers to avoid KeyError when upstream nodes are inconsistent.
  • Include at least one path to END in 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

Can a routing function be async?

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.

What's the difference between returning a raw node name and using path_map?

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.

Why does my parallel fan-out produce inconsistent state?

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.

Can I use <code>Command</code> instead of conditional edges?

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.

How do I debug a router that returns the wrong branch?

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.

Related errors