LangGraph conditional edges are functions that inspect graph state and return the next node name, enabling dynamic routing between agents without hardcoding transitions. Unlike static edges that always follow the same path, conditional edges let a single node fan out to multiple destinations based on runtime values — tool results, model outputs, or arbitrary state fields. This mechanism is the backbone of langgraph conditional edges routing in multi-agent systems.
How conditional edges work
At the graph level, a conditional edge is a callable that receives the current State object and returns a string (the next node name) or a list of strings for parallel execution. You register it with add_conditional_edges instead of add_edge. The function runs every time the source node completes, giving you a decision point before the graph continues.
from langgraph.graph import StateGraph, END
from typing import Literal
def route_after_classifier(state: State) -> Literal["researcher", "coder", "reviewer"]:
decision = state["classification"]
if decision == "research":
return "researcher"
elif decision == "code":
return "coder"
return "reviewer"
graph = StateGraph(State)
graph.add_node("classifier", classifier_node)
graph.add_node("researcher", researcher_node)
graph.add_node("coder", coder_node)
graph.add_node("reviewer", reviewer_node)
graph.add_conditional_edges("classifier", route_after_classifier)
graph.add_edge("researcher", END)
graph.add_edge("coder", END)
graph.add_edge("reviewer", END)
The routing function can be synchronous or async. It has full access to state — including messages, tool outputs, and any custom fields you’ve added. Return END to terminate the graph. Return a list like ["node_a", "node_b"] to fan out to multiple nodes in parallel (LangGraph executes them concurrently).
Why conditional edges matter for multi-agent workflows
Static graphs force every request through the same sequence. Real workloads don’t work that way. A coding task needs a planner, then a coder, then a tester. A research task needs a search agent, then a synthesizer, then a fact-checker. A simple Q&A might need none of the above.
Conditional edges let you:
- Route by intent: Classify the user request once, then dispatch to the right specialist agent.
- Implement guardrails: Check tool outputs for errors, policy violations, or low confidence, then route to a recovery agent or human escalation.
- Enable iterative loops: Send the output of a generator back to a critic until quality thresholds are met, then exit.
- Compose reusable subgraphs: Each agent can be its own compiled graph; the parent graph routes between them without knowing their internals.
This is where langgraph conditional edges routing becomes a composition primitive, not just a control flow feature. You build agents as black boxes, then wire them together with routing logic that lives outside any single agent.
Concrete example: triage → specialist → reviewer
Consider a support ticket system. Tickets arrive, get classified, route to a domain specialist (billing, technical, account), then pass through a quality reviewer before closing. The reviewer can bounce tickets back to the specialist with feedback.
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, END
from langchain_core.messages import BaseMessage
class TicketState(TypedDict):
messages: list[BaseMessage]
category: Literal["billing", "technical", "account", "unknown"]
specialist_output: str
review_feedback: str
retry_count: int
def triage(state: TicketState) -> Literal["billing_agent", "technical_agent", "account_agent", "escalate"]:
# In practice, this calls an LLM or classifier
category = state["category"]
if category == "billing":
return "billing_agent"
elif category == "technical":
return "technical_agent"
elif category == "account":
return "account_agent"
return "escalate"
def review_quality(state: TicketState) -> Literal["close", "retry", "escalate"]:
feedback = state["review_feedback"]
retries = state["retry_count"]
if "PASS" in feedback.upper():
return "close"
if retries >= 2:
return "escalate"
return "retry"
def billing_agent(state: TicketState) -> TicketState:
# ... process billing ticket ...
return {"specialist_output": "Resolved billing issue", "retry_count": 0}
def technical_agent(state: TicketState) -> TicketState:
# ... process technical ticket ...
return {"specialist_output": "Resolved technical issue", "retry_count": 0}
def account_agent(state: TicketState) -> TicketState:
# ... process account ticket ...
return {"specialist_output": "Resolved account issue", "retry_count": 0}
def reviewer(state: TicketState) -> TicketState:
# ... review specialist_output ...
return {"review_feedback": "PASS", "retry_count": state.get("retry_count", 0) + 1}
def escalate_node(state: TicketState) -> TicketState:
return {"specialist_output": "Escalated to human"}
graph = StateGraph(TicketState)
graph.add_node("triage", lambda s: s) # no-op, just a routing point
graph.add_node("billing_agent", billing_agent)
graph.add_node("technical_agent", technical_agent)
graph.add_node("account_agent", account_agent)
graph.add_node("reviewer", reviewer)
graph.add_node("escalate", escalate_node)
graph.add_conditional_edges("triage", triage)
graph.add_edge("billing_agent", "reviewer")
graph.add_edge("technical_agent", "reviewer")
graph.add_edge("account_agent", "reviewer")
graph.add_conditional_edges("reviewer", review_quality, {
"close": END,
"retry": "triage", # loop back with incremented retry_count
"escalate": "escalate"
})
graph.add_edge("escalate", END)
graph.set_entry_point("triage")
app = graph.compile()
Key details in this example:
- The
triagenode is a no-op; all routing logic lives in the conditional edge function. This keeps nodes pure and testable. - The
review_qualityfunction implements a bounded retry loop. Theretry_countin state prevents infinite cycles. - The conditional edge returns a mapping dictionary (
{"close": END, "retry": "triage", ...}) — this is optional but recommended. It documents valid transitions and lets LangGraph validate the graph at compile time. - Specialists don’t know about each other or the reviewer. They only produce output. The graph structure handles coordination.
Common misconceptions
“Conditional edges are just if/else statements in the graph”
They look like if/else, but the distinction matters. An if/else inside a node couples routing logic to that node’s implementation. A conditional edge separates routing from execution. This means:
- You can change routing without touching agent code.
- Multiple nodes can share the same routing function.
- Routing logic is testable in isolation — pass a
Statedict, assert the returned node name. - Visualization tools (LangGraph Studio, Mermaid export) show the decision point explicitly.
“You need a separate node for every decision”
A single node can have multiple conditional edges leaving it, each with different routing functions. You can also chain conditional edges: node_a → conditional → node_b → conditional → node_c. The graph doesn’t care how many decision points exist.
“Conditional edges hurt observability”
The opposite. Because routing is explicit in the graph definition, every transition appears in traces. You see which condition fired, what state values drove it, and where execution went next. Compare this to implicit routing buried inside a monolithic agent prompt — there, you only see the final output.
“Parallel fan-out requires special syntax”
Return a list from your routing function: return ["node_a", "node_b"]. LangGraph executes both in parallel, waits for both to complete, then merges state (last-write-wins per key, or you can provide a custom reducer). This is useful for “gather” patterns — e.g., send a query to both a web search agent and a code search agent simultaneously.
“State must be a Pydantic model”
LangGraph accepts TypedDict, dataclass, Pydantic BaseModel, or plain dict. Use whatever fits your codebase. TypedDict gives you IDE autocomplete without runtime overhead. Pydantic gives you validation. Both work with conditional edges.
Advanced patterns
Routing with structured output
Instead of parsing free-text LLM output, use structured output (function calling / tool calling) to produce a routing decision directly:
from pydantic import BaseModel
from langchain_core.utils.function_calling import convert_to_openai_tool
class RoutingDecision(BaseModel):
next_node: Literal["researcher", "coder", "reviewer"]
reasoning: str
routing_tool = convert_to_openai_tool(RoutingDecision)
def llm_router(state: State) -> Literal["researcher", "coder", "reviewer"]:
response = llm_with_tools.invoke(state["messages"])
tool_call = response.tool_calls[0]
return tool_call["args"]["next_node"]
This eliminates prompt engineering for routing and makes the decision auditable — the reasoning field appears in traces.
Dynamic agent registration
In systems where agents are plugins, you can build the routing function at startup:
def make_router(agents: dict[str, Agent]) -> Callable[[State], str]:
def route(state: State) -> str:
intent = classify_intent(state["messages"][-1].content)
return agents.get(intent, agents["default"]).name
return route
graph.add_conditional_edges("dispatcher", make_router(registry))
New agents register themselves; the graph picks them up without recompilation.
Conditional edges with subgraphs
Each specialist in the ticket example could be a compiled subgraph with its own internal nodes (planner → executor → verifier). The parent graph only sees the subgraph as a single node. This scales: the parent stays simple while subgraphs grow complex independently.
billing_subgraph = build_billing_graph().compile()
technical_subgraph = build_technical_graph().compile()
parent = StateGraph(TicketState)
parent.add_node("billing", billing_subgraph)
parent.add_node("technical", technical_subgraph)
# ... conditional edges route to "billing" or "technical" ...
When to avoid conditional edges
- Linear pipelines: If every request follows the same sequence, static edges are clearer.
- Single-agent systems: No routing needed.
- Extremely high-throughput paths: Each conditional edge adds a function call overhead. Negligible for LLM latency, but measurable in tight loops with thousands of iterations.
Testing conditional edges
Test the routing function in isolation:
import pytest
def test_triage_routes_billing():
state = {"category": "billing", "messages": [], "retry_count": 0}
assert triage(state) == "billing_agent"
def test_review_loops_twice_then_escalates():
state = {"review_feedback": "FAIL", "retry_count": 0}
assert review_quality(state) == "retry"
state = {"review_feedback": "FAIL", "retry_count": 1}
assert review_quality(state) == "retry"
state = {"review_feedback": "FAIL", "retry_count": 2}
assert review_quality(state) == "escalate"
Test the compiled graph with app.invoke() for integration coverage. LangGraph’s MemorySaver checkpointer lets you inspect intermediate state at each step.
Summary
LangGraph conditional edges routing is the mechanism that turns a static DAG into a dynamic, state-driven workflow. The routing function is a pure decision point — input state, output next node name. Keep it pure, test it in isolation, and use the mapping dictionary form for documentation and validation. Build agents as independent subgraphs, then compose them with conditional edges at the top level. This scales from three agents to thirty without the parent graph becoming a spaghetti mess.