teachyou.ai academy
← All posts
LangGraph

LangGraph Conditional Edges: Building Branching Agent Logic

Ira Menon · Jun 20, 2026 · 14 min read

Why Your Agent Graph Needs More Than Straight Lines

Every LangGraph tutorial starts the same way: build a linear chain, add a node, connect it to another node, run it. That works fine until your agent needs to make a decision. Should it call a tool or answer directly? Should it retry a failed step or give up? Should it loop back for another reasoning pass or move on to the final response?

A plain edge in LangGraph always goes from node A to node B, no exceptions. That's not how real agents behave. Real agents check state, evaluate a condition, and pick one of several possible next steps. This is exactly what conditional edges are for, and once you understand them, you stop building rigid pipelines and start building agents that actually reason about what to do next.

This article walks through how conditional edges work under the hood, how to write the routing functions that drive them, and the mistakes that trip up almost everyone the first time they wire one up. By the end, you'll have working code for a tool-calling agent, a retry loop, and a multi-way router, plus a mental model for debugging conditional logic when it doesn't route the way you expect.

What a Conditional Edge Actually Is

In LangGraph, your application is a graph made of nodes (functions that read and update state) and edges (the connections that determine execution order). A standard edge is unconditional — you declare it once with add_edge("node_a", "node_b") and the graph always moves from node_a to node_b.

A conditional edge replaces that fixed destination with a function. After a node finishes running, LangGraph calls your routing function, passes it the current state, and uses the return value to decide which node runs next. The routing function itself doesn't modify state — its only job is to look at what's already there and return a string (or a list of strings) naming the next node.

The method you use is add_conditional_edges, and its signature looks like this:

graph.add_conditional_edges(
    source,           # the node this logic applies after
    path,             # your routing function
    path_map=None,    # optional dict mapping return values to node names
)

Three things matter here. First, source is the node whose output triggers the check — the routing function runs immediately after this node executes. Second, path is any callable that takes the graph state and returns a string identifying the destination. Third, path_map is optional but valuable: it lets you decouple the literal strings your function returns from the actual node names in your graph, which makes refactoring much safer.

Compare this to a static edge:

# Static edge — always the same destination
graph.add_edge("fetch_data", "summarize")

# Conditional edge — destination depends on state
graph.add_conditional_edges("fetch_data", decide_next_step)

That's the entire mental model. Everything else is just detail on how to write good routing functions and wire them into realistic agent patterns.

The Classic Case: Tool-Calling Agents

The most common use of conditional edges is deciding whether an LLM's response should trigger a tool call or end the turn. This is the backbone of the ReAct-style agent pattern, and it's worth building from scratch so you see every moving part.

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, END, START
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langchain_core.messages import AIMessage
from langchain_anthropic import ChatAnthropic
from langchain_core.tools import tool


class AgentState(TypedDict):
    messages: Annotated[list, add_messages]


@tool
def get_weather(city: str) -> str:
    """Return current weather for a given city."""
    return f"It is sunny and 24C in {city}."


tools = [get_weather]
llm = ChatAnthropic(model="claude-sonnet-4-5").bind_tools(tools)


def call_model(state: AgentState):
    response = llm.invoke(state["messages"])
    return {"messages": [response]}


def route_after_model(state: AgentState) -> str:
    last_message = state["messages"][-1]
    if isinstance(last_message, AIMessage) and last_message.tool_calls:
        return "tools"
    return END


builder = StateGraph(AgentState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools))

builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route_after_model)
builder.add_edge("tools", "agent")

graph = builder.compile()

Walk through the flow. The graph starts at agent, which calls the model. The model either responds directly or asks for a tool. The routing function route_after_model inspects the last message in state: if it has tool_calls attached, we go to tools; otherwise we're done and route to END. After the tools node runs, we send control back to agent unconditionally, so the model can see the tool's output and decide what to do next.

Note that route_after_model returns raw node name strings directly — "tools" or END. When your return values line up exactly with node names, you don't need a path_map at all. Add one when you want your function to return abstract labels like "continue" or "stop" instead of literal node names — useful when node names might change later:

def route_after_model(state: AgentState) -> str:
    last_message = state["messages"][-1]
    if isinstance(last_message, AIMessage) and last_message.tool_calls:
        return "continue"
    return "stop"


builder.add_conditional_edges(
    "agent",
    route_after_model,
    {"continue": "tools", "stop": END},
)

Both versions behave identically at runtime. The path_map version is slightly more maintainable in larger graphs because renaming a node only requires updating the dictionary, not hunting through every routing function for string literals.

Multi-Way Routing: Beyond Yes/No Branches

Conditional edges aren't limited to two outcomes. A routing function can return one of any number of node names, which is how you build classifiers, dispatchers, and triage logic directly into the graph structure.

Say you're building a support-ticket agent that needs to send incoming requests down different paths depending on category:

from typing import Literal


class TicketState(TypedDict):
    messages: Annotated[list, add_messages]
    category: str


def classify_ticket(state: TicketState):
    last = state["messages"][-1].content.lower()
    if "refund" in last or "billing" in last:
        category = "billing"
    elif "bug" in last or "error" in last:
        category = "technical"
    else:
        category = "general"
    return {"category": category}


def route_by_category(state: TicketState) -> Literal["billing", "technical", "general"]:
    return state["category"]


builder = StateGraph(TicketState)
builder.add_node("classify", classify_ticket)
builder.add_node("billing", lambda s: {"messages": [AIMessage(content="Routing to billing team.")]})
builder.add_node("technical", lambda s: {"messages": [AIMessage(content="Routing to technical support.")]})
builder.add_node("general", lambda s: {"messages": [AIMessage(content="Routing to general support.")]})

builder.add_edge(START, "classify")
builder.add_conditional_edges(
    "classify",
    route_by_category,
    {"billing": "billing", "technical": "technical", "general": "general"},
)
builder.add_edge("billing", END)
builder.add_edge("technical", END)
builder.add_edge("general", END)

graph = builder.compile()

Here classify_ticket writes the decision into state as a plain field, and route_by_category simply reads it back out. This split matters: keep classification logic (which might call an LLM, hit an API, or run a regex) inside a node, and keep the routing function itself as a cheap, side-effect-free lookup. Routing functions run synchronously as part of graph traversal — they should never make network calls or do heavy computation. If a decision requires an LLM call, do that work inside a regular node and store the result in state, then let a thin routing function read it.

Type-hinting the return value with Literal["billing", "technical", "general"] is optional but worth doing. LangGraph doesn't enforce it at runtime, but it gives you autocomplete and makes it obvious to the next reader exactly which destinations are valid.

Building Retry and Validation Loops

Conditional edges are also how you implement loops — retrying a failed step, re-prompting on invalid output, or iterating until some quality bar is met. This is one of the biggest reasons to move beyond a naive chain: a chain can't loop back on itself, but a graph can.

class ValidationState(TypedDict):
    messages: Annotated[list, add_messages]
    draft: str
    attempts: int
    valid: bool


MAX_ATTEMPTS = 3


def generate_draft(state: ValidationState):
    response = llm.invoke(state["messages"] + [
        ("human", "Write a one-paragraph product description.")
    ])
    return {
        "draft": response.content,
        "attempts": state.get("attempts", 0) + 1,
    }


def validate_draft(state: ValidationState):
    draft = state["draft"]
    is_valid = len(draft.split()) >= 30 and "product" in draft.lower()
    return {"valid": is_valid}


def route_after_validation(state: ValidationState) -> str:
    if state["valid"]:
        return "done"
    if state["attempts"] >= MAX_ATTEMPTS:
        return "give_up"
    return "retry"


builder = StateGraph(ValidationState)
builder.add_node("generate", generate_draft)
builder.add_node("validate", validate_draft)
builder.add_node("done", lambda s: s)
builder.add_node("give_up", lambda s: {"draft": "Could not produce a valid draft."})

builder.add_edge(START, "generate")
builder.add_edge("generate", "validate")
builder.add_conditional_edges(
    "validate",
    route_after_validation,
    {"done": END, "retry": "generate", "give_up": END},
)

graph = builder.compile()

The critical safety net here is attempts. Without a hard ceiling, a conditional edge that routes back to generate on every failure creates an infinite loop the moment validation never passes — which happens more often than you'd expect once real-world inputs hit your prompt. Always track an iteration counter in state and give your routing function an explicit exit condition tied to it. LangGraph's compile() also accepts a recursion_limit you can pass at invocation time as a second line of defense:

graph.invoke(initial_state, config={"recursion_limit": 25})

Treat the recursion limit as a safety net, not a substitute for your own loop-termination logic. Hitting it means your graph raises a GraphRecursionError, which is a much worse user experience than a clean give_up branch you designed on purpose.

Routing on Structured State vs. Message Content

A subtlety that catches people early on: your routing function can inspect *any* field in the state schema, not just the most recent message. This opens up two broad styles of routing logic, and picking the right one for the situation matters.

Message-based routing looks at the content or metadata of the last message — checking for tool_calls, scanning text for keywords, or checking a message's role. This is natural for conversational agents where the "decision" is really just "what did the model just say."

State-based routing looks at dedicated fields you've deliberately written into your state schema — counters, boolean flags, enums, confidence scores. This is more robust for anything beyond trivial branching because it doesn't depend on parsing free text. Instead of asking "does the message contain the word 'error'," you have a node that classifies the outcome and writes state["status"] = "error", and your routing function does a clean dictionary-style lookup.

class ResearchState(TypedDict):
    messages: Annotated[list, add_messages]
    confidence: float
    sources_checked: int


def route_on_confidence(state: ResearchState) -> str:
    if state["confidence"] >= 0.8:
        return "finalize"
    if state["sources_checked"] >= 5:
        return "finalize"   # give up gracefully rather than loop forever
    return "search_more"

As a rule of thumb: reach for state-based routing whenever the decision involves anything numeric, multi-step, or reusable across nodes. Reserve message-based routing for the simple, single-purpose case of "did the LLM ask for a tool." Mixing both is fine — the tool-calling check in the earlier example is message-based because that's genuinely the simplest correct way to detect a tool call, since tool_calls is a structured attribute on the message object, not something you're regex-matching out of text.

Debugging Conditional Edges That Route Wrong

Conditional edges fail in a small number of predictable ways, and knowing the checklist saves a lot of guesswork.

  • The routing function returns a value with no matching entry in `path_map`. LangGraph raises an error at invocation time because it can't resolve the next node. Double check that every possible return value — including edge cases like empty strings or None — has a corresponding destination.
  • You forgot to register a destination node with `add_node` before referencing it in `path_map`. The graph compiles the mapping against your actual node registry, so a typo in a string literal produces a hard failure, not a silent misroute.
  • State isn't updated before the routing function runs. Remember that add_conditional_edges(source, path) calls path only after source finishes executing and its return value has been merged into state. If your routing logic depends on a field, make sure the node right before it actually writes that field — a common bug is checking state["valid"] when the validation node returned is_valid as the key instead.
  • Routing functions have side effects. If your routing function calls an API or mutates a shared object, you'll get inconsistent behavior under retries or when LangGraph replays state during debugging. Keep routing functions pure — read state in, return a string out, nothing else.
  • Infinite loops from missing termination conditions. Covered above, but worth repeating: any conditional edge that can route back to an earlier node needs a counter or flag that eventually forces an exit.

A good habit is to log the routing decision every time, at least during development:

def route_after_model(state: AgentState) -> str:
    last_message = state["messages"][-1]
    decision = "tools" if (isinstance(last_message, AIMessage) and last_message.tool_calls) else END
    print(f"[routing] agent -> {decision}")
    return decision

This one line saves enormous debugging time on graphs with three or four possible branches, because it's often not obvious from the final output alone which path the agent actually took.

Combining Conditional Edges with Command for Dynamic Control

Newer versions of LangGraph also support the Command object, which lets a node return both a state update and a routing decision in a single return value, instead of splitting that logic between a node and a separate add_conditional_edges call. It's worth knowing about because it changes how you structure some agents.

from langgraph.types import Command
from typing import Literal


def generate_draft(state: ValidationState) -> Command[Literal["validate", "generate"]]:
    response = llm.invoke(state["messages"])
    attempts = state.get("attempts", 0) + 1

    goto = "validate"
    return Command(
        update={"draft": response.content, "attempts": attempts},
        goto=goto,
    )

With Command, the node itself decides where control goes next, which collapses the node-plus-routing-function pattern into one function. This is convenient for simple cases, but it also mixes business logic with control flow inside a single function, which can make larger graphs harder to read. A reasonable guideline: use add_conditional_edges when the routing logic is reusable, testable independently, or shared across multiple source nodes. Reach for Command when a single node genuinely owns its own next-step decision and splitting it out would just be indirection for its own sake. Both approaches compile down to the same underlying graph structure, so this is a stylistic choice, not a performance one.

Practical Patterns Worth Reusing

A few patterns show up repeatedly once you start building non-trivial graphs, and it's worth having them as templates.

  • Guard clause routing: check the cheapest, most likely-to-short-circuit condition first inside your routing function, so common cases resolve in a single comparison rather than falling through several elif branches.
  • Fallback branch: always include a default return value at the end of a routing function, even if you think you've covered every case. An unhandled state combination should route somewhere sane (often an error-handling node) rather than falling through to an exception.
  • Named constants for node names: instead of scattering string literals like "tools" or "validate" across your codebase, define them once as module-level constants. This turns a typo into an import error at load time instead of a silent misroute at runtime.
  • Separate classification from routing: as shown in the ticket example, let a node do the (possibly expensive) work of deciding what category something falls into, and let the routing function be a fast, pure lookup against that result.
  • Test routing functions in isolation: because a routing function is just state -> str, you can unit test it directly without running the whole graph. Construct a fake state dictionary, call the function, and assert on the returned node name. This catches branch coverage gaps far faster than running the full agent end to end.
def test_route_after_model_returns_tools_when_tool_call_present():
    fake_state = {
        "messages": [AIMessage(content="", tool_calls=[{"name": "get_weather", "args": {}, "id": "1"}])]
    }
    assert route_after_model(fake_state) == "tools"


def test_route_after_model_returns_end_when_no_tool_call():
    fake_state = {"messages": [AIMessage(content="Here's your answer.")]}
    assert route_after_model(fake_state) == END

Treating routing functions as first-class, independently testable units is probably the single biggest quality improvement you can make to a LangGraph codebase as it grows past a handful of nodes.

Wrapping Up

Conditional edges are what turn a LangGraph graph from a fixed pipeline into something that behaves like an actual agent — checking its own state, deciding between multiple next steps, looping when it needs another pass, and exiting cleanly when it's done. The mechanics are simple: add_conditional_edges takes a source node and a routing function, and the routing function's return value picks the destination, optionally through a path_map. The judgment calls are in the details — keeping routing functions pure, choosing state-based over message-based checks when logic gets more complex, guarding every loop with an iteration limit, and deciding when Command is a cleaner fit than a separate routing function.

If you want to go deeper — building multi-agent supervisors, persistent memory across conditional loops, and production-grade error handling for branching graphs — that's exactly what we cover hands-on in the LangGraph Tutorial course on teachyou.ai, with real projects instead of toy examples.