skip to content

Conditional Routing

You will learn how control flow branches: routing functions with add_conditional_edges, dynamic branching on state, fan-out to parallel branches, join patterns, and the Send API for map-reduce style work. Interviewers ask because conditional edges are where an agent's decisions become explicit and auditable instead of buried inside a prompt.

part ofAI agent & RAG frameworksoverview, primer and where to startread it →
on this pageshow

questions

5

In LangGraph, how does add_conditional_edges decide which node runs next?

level: middleimportance: must knowfreq 72%

answer

  1. it is a function of state, not a node
  2. runs after the source node's update lands
  3. returns a destination, never a state update
  4. a list return means parallel fan-out
  5. unmapped return value raises at runtime

basics

~20 s

add_conditional_edges attaches a router function to a source node. After that node runs and its update is applied, LangGraph calls the router with the current state and uses its return value to name the next node, or END to stop that path.

solid answer

~50 s

`builder.add_conditional_edges(source, path, path_map)` registers a plain Python callable — the router — against a source node. When the source node finishes, LangGraph applies its state update first, then calls the router with the resulting state; the router may be sync or async. Its return value is either a node name, `END`, a key of the optional `path_map` that resolves to one of those, or a **list** of them, which fans out to several nodes in the next step. The router is a decision function only: it is not a node, it cannot write to state, and it is not checkpointed or emitted as a step in streaming. Anything the decision needs must already be in state, written by the node before it. If the router returns a value that maps to no destination, the run raises at that point rather than at compile time.

code

python · 24 lines
python
from typing import Literal, TypedDict

from langgraph.graph import END, START, StateGraph


class State(TypedDict):
    count: int


def tick(state: State) -> dict:
    return {"count": state["count"] + 1}


def route(state: State) -> Literal["again", "stop"]:
    return "again" if state["count"] < 3 else "stop"


builder = StateGraph(State)
builder.add_node("tick", tick)
builder.add_edge(START, "tick")
builder.add_conditional_edges("tick", route, {"again": "tick", "stop": END})
graph = builder.compile()

print(graph.invoke({"count": 0}))  # {'count': 3}

go deeper

for a junior

Know that add_conditional_edges takes a source node and a function, and that the function returns where to go next — a node name or END. Be able to write a two-branch router over a simple state key.

for a middle

Explain that the router is called with the full post-update state, may return a list to fan out, and cannot write state. Mention that an unmapped return value fails at runtime, not at compile time.

for a senior

Show the operational angle: routers are unit-testable pure functions, unbounded cycles need an explicit budget in state rather than a raised recursion limit, and routing decisions worth auditing must be written to state by a node because the router is not checkpointed.

for a principal

Own the placement question — which decisions deserve to be edges in the graph at all, how much logic a router may hold before the diagram stops describing the system, and how branch structure interacts with observability and resumability across a fleet of graphs.

## The problem conditional edges solve A LangGraph graph is nodes plus edges. A node is a function that receives the state and returns a partial update; an edge says what runs next. `add_edge("a", "b")` is unconditional — after `a`, always `b`. Agent-shaped work needs the other kind: after the model node runs, go to the tool node if the model asked for a tool, otherwise go to the responder or finish. `add_conditional_edges` is how that branch becomes an explicit, drawable part of the graph instead of an `if` buried in a node body. ## The call `builder.add_conditional_edges(source, path, path_map=None)` takes three things: the name of the source node (`START` is also legal as a source), the router callable, and an optional mapping from the router's return values to destination node names. When `path_map` is omitted, the router must return actual node names or `END`. ## The router's contract The router is an ordinary function, not a node. It is called with the **full graph state as it exists after the source node's update has been merged** — not with whatever dict the source node returned. So a router can read any key in the schema, including keys written many steps earlier. It may be `def` or `async def`. What it may return: - a single node name string; - `END` (the sentinel exported from `langgraph.graph`, whose value is the string `"__end__"`), which terminates that path; - a key of `path_map`, which is then resolved to a node name or `END`; - a list of any of the above, which schedules every named node to run in the next superstep, in parallel. What it may **not** do: change state. A router's return value is interpreted purely as routing. If you compute something useful inside the router and want it persisted, you have to move that computation into the source node and write it to state there. ## Where it sits in execution LangGraph executes in supersteps: a set of nodes runs, their writes are applied to the channels, a checkpoint is written if a checkpointer is configured, and then the next set of nodes is chosen. The router runs at that boundary. Consequences that show up in practice: the router is not a separately checkpointed step, so you cannot pause at it; it does not appear as a node in the drawn graph or in per-node streaming output; and a slow or throwing router fails the transition rather than a node. ## Fan-out and termination Returning a list is the simplest fan-out mechanism: `return ["summarize", "classify"]` runs both nodes concurrently in the next step, and their writes must be reconcilable — a plain key written by both raises, an annotated key with a reducer accumulates. Returning `END` from one branch ends only that path; other paths already scheduled continue to run. ## Typical failure modes 1. **A return value with no destination.** Node names are not validated against arbitrary router outputs at compile time, so a typo or an unmapped literal surfaces at runtime, mid-run, often after a paid model call. 2. **Routing on state the node never wrote.** The router sees the post-update state, so a router reading `state["decision"]` when the node forgot to return that key reads the previous value (or raises a `KeyError` on a TypedDict that never had it). 3. **Unbounded loops.** Conditional edges are what make cycles possible — a router that keeps returning the same node loops forever. LangGraph guards this with a recursion limit (25 steps by default, overridable per call via the `recursion_limit` config key) and raises `GraphRecursionError` when it is exceeded. Treat that error as a routing bug, not as a limit to raise blindly; usually the fix is a counter or an attempt cap in state that the router checks. 4. **Hiding the decision.** If the router is 40 lines of business logic, the graph diagram lies about where the complexity lives. Routers should be small and readable. ## Testing Because the router is a plain function of state, it is the cheapest thing in the whole graph to unit test: call it with hand-built state dicts and assert on the returned label. Do that instead of running the whole graph to exercise a branch.

  • Can the router itself update the state while it decides?
    No. Whatever a router returns is interpreted as routing, never as a state update, and there is no second return channel. If the decision produces data worth keeping — a chosen strategy, a parsed intent, a retry counter — the source node must compute and return it, and the router then reads it from state. Keeping routers pure also makes them trivially unit-testable as functions of state.
  • What stops a conditional edge that keeps routing back to the same node from looping forever?
    LangGraph counts supersteps and raises GraphRecursionError once the recursion limit is hit — 25 by default, settable per invocation through the recursion_limit config key. That is a backstop, not a design. The real fix is an explicit budget in the state: have the node increment an attempt counter and have the router return END once it crosses a threshold, so the graph terminates deliberately rather than by exception.
  • Where does the router run relative to checkpointing and streaming?
    At the boundary between supersteps, after the source node's writes are applied. It is not a node, so it produces no checkpoint of its own, appears in no per-node stream output, and cannot be a pause point. If you need to observe or intervene on a branch decision, the decision has to be materialised into state by a node first — then it is visible in the state snapshot.

saying these in an interview costs you the question

  • Thinking the router receives only the source node's return dict
  • Believing a router can return a state update alongside the destination
  • Assuming node names in a router are validated at compile time
  • Treating the router as a node that shows up in traces and checkpoints
  • Raising recursion_limit instead of fixing a router that never terminates

context

open as a page

When two parallel LangGraph branches write the same state key, what happens?

level: seniorimportance: must knowfreq 55%

basics

~20 s

If the key has no reducer, LangGraph raises InvalidUpdateError — it refuses to pick a winner between two writes in one step. Annotate the key with a reducer, such as Annotated[list, operator.add], and both writes are combined instead.

open as a page

In LangGraph, what does Send() in a routing function do that returning node names cannot?

level: seniorimportance: must knowfreq 52%

basics

~20 s

Send lets one branch spawn a runtime-determined number of parallel invocations of the same node, each with its own private input state. Returning node names can only fan out to a fixed set of nodes, all sharing the graph state.

open as a page

Why pass path_map to LangGraph's add_conditional_edges if the router returns node names?

level: middleimportance: should knowfreq 42%

basics

~20 s

path_map translates the router's return labels into destination node names, so the router speaks intent ("continue", "done") rather than topology. It also tells LangGraph the branch's possible destinations up front, which is what makes the drawn graph show real edges.

open as a page

When should LangGraph routing live in a conditional edge instead of inside a node?

level: principalimportance: should knowfreq 34%

basics

~20 s

Make the branch a conditional edge when someone needs to see, pause, resume, or retry at that decision point: edges are drawn in the graph, land on step boundaries, and split work into separately observable nodes. Keep trivial in-node conditionals inline.

open as a page