← Back to Agentic AI map
Lesson 3.3 · Building with LangGraph

Conditional Edges

Let the state choose the next node: router functions, add_conditional_edges, path maps - and llama3 doing the classifying.

langgraph

What you will be able to do

  • Explain the difference between a normal edge and a conditional edge
  • Write a router function that reads the state and returns where to go
  • Use add_conditional_edges with a path map - and know why the map matters
  • Route on more than one state field, and route to END
  • Use an LLM to classify and the graph to route
  • Spot the silent failures: a misspelled route, a missing edge, a router that tries to update state

The idea, in plain English

A normal edge always goes to the same node. A conditional edge asks a function: add_conditional_edges("classify", route_request, path_map) runs route_request on the current state after classify finishes and follows the edge it names. One node can now lead to billing, technical or account - whichever the state calls for.

Keep three jobs apart. A classifier node works out what the request is and writes it into the state (category = "billing"). A router function reads the state and only answers "where next?" - it returns a name, does no work, and cannot change the state. Worker nodes do the actual work. Classification, routing, execution.

The classifier is where a model earns its place. A keyword rule caught 3 of our 8 support questions; llama3 with structured output caught all 8 - "I was billed twice", "Can I get a refund?", "My card was declined" have no "charge" or "payment" in them. The model handles the language, the graph handles the workflow, and the routing stays a plain, testable function.

Everything below was run with LangGraph 1.2.14, and the LLM classifier with llama3 - a chat model with structured output, no tool calling needed.

Worked example: A support router: billing, technical, account.

workflowOne classifier, three routesstep 1 / 4

1 - Keyword classifier: the wrong branch

The keyword rule looks for "charge" or "payment". "Billed" is neither, so it writes category = technical, and the router faithfully sends the question to the wrong team.

keyword category
technical
router returned
"technical"
keyword score
3 of 8 questions
the router
did its job

"I was billed twice this month" through a support router - with a keyword classifier, then with llama3. Real runs.

Normal edge, conditional edge

builder.add_edge("classify", "billing") means classify is always followed by billing. builder.add_conditional_edges("classify", route_request, ["billing", "technical", "account"]) means: after classify, call route_request(state) and go wherever it says - one of those three.

The decision can rest on anything in the state: a classification, a user flag, a tool result, a validation outcome, a retry count. Only the chosen branch runs, so a question about refunds never costs a call to the technical agent.

The router function

A router takes the state and returns a destination - a node name, a key from the path map, or END. if state["category"] == "billing": return "billing", otherwise "technical". That is all it should do.

It cannot change the state. A router that returned {"category": "billing"} instead of a name was ignored with the warning "wrote to unknown channel ... ignoring it". If a decision needs to be recorded, a node records it; the router only reads.

Tip: Routers are plain functions of the state, so they are the easiest part of a graph to unit-test: build a state dict, call the router, assert the name.

Always give a path map

The third argument tells LangGraph where the router can go - a list of node names, or a dictionary from router results to nodes: {"BILLING_PATH": "billing", "TECHNICAL_PATH": "technical"}. It looks optional. It is not, for two reasons we hit in testing.

First, mistakes. Without a map, a router that returned "biling" did not raise: LangGraph logged a warning, ran nothing after classify, and invoke() returned a state with no response at all. With a map, the same typo raised KeyError: ‘biling’. Second, the picture: without a map, get_graph() drew classify --> __end__ as if the branches did not exist; with a map - or a Literal["billing", "technical"] return type on the router - it drew classify -.-> billing and classify -.-> technical.

Path map or not - checked
Router returns a valid name, no mapWorks.
Router returns "biling", no mapNo error. Warning in the log, run ends after classify, no response.
Router returns "biling", with a mapKeyError: ‘biling’ - the mistake is reported.
Drawing, no mapclassify --> __end__ - branches missing.
Drawing, with a map or Literal return typeclassify -.-> billing, classify -.-> technical.

Watch out: A misspelled route without a path map fails silently. Pass the map - a list of node names is enough - and the typo becomes a KeyError.

Routing on several fields, and to END

A router can combine state fields into business rules: if not state["authenticated"]: return "login", then route by category. In our run an unauthenticated payment question got "Please log in first." and an authenticated one went to billing.

A router can also return END to finish the run at once - useful for "nothing to do" cases. And because a conditional edge can name a node that already ran, it can also create a loop, which is Lesson 3.4.

The model classifies, the graph routes

Keyword rules are brittle in exactly the way language is flexible. The two-category keyword classifier from the example got 3 of 8 support questions right: it sent "I was billed twice", "Can I get a refund?" and "My card was declined" to technical, and it has no account category at all for "I forgot my password".

Replacing only the classify node with llama3 - with_structured_output on a Category model whose field is Literal["billing", "technical", "account"] - got all 8 right, in about half a second each. The router and the workers did not change. The Literal is what keeps it safe: the model can only produce a value the router knows.

Use the model where interpretation is needed and plain code where it is not. "Is the user logged in?" is a boolean, not a language question - a deterministic rule is cheaper, faster and testable.

Eight questions, two classifiers - real runs
Why was I charged twice?keyword: billing ✓ llama3: billing ✓
Why is my API returning HTTP 500?keyword: technical ✓ llama3: technical ✓
I was billed twice this monthkeyword: technical ✗ llama3: billing ✓
Can I get a refund?keyword: technical ✗ llama3: billing ✓
My card was declinedkeyword: technical ✗ llama3: billing ✓
I forgot my passwordkeyword: technical ✗ llama3: account ✓
I cannot log into my accountkeyword: technical ✗ llama3: account ✓
The app crashes when I upload a filekeyword: technical ✓ llama3: technical ✓

Why not an if inside one node?

def process(state): if billing: ... elif technical: ... works for two cases. As the cases, retries and checks grow, everything piles into one function and the workflow disappears inside it. With conditional edges the decisions are visible in the graph itself, each worker is a separate node you can test, stream and trace, and adding a route is one node and one map entry.

Step-by-step code

Classify, route, work
from typing import TypedDict from langgraph.graph import END, START, StateGraph class State(TypedDict): question: str category: str response: str def classify(state: State): question = state["question"].lower() if "charge" in question or "payment" in question: return {"category": "billing"} return {"category": "technical"} def billing(state: State): return {"response": "This looks like a billing-related issue."} def technical(state: State): return {"response": "This looks like a technical issue."} def route_request(state: State): if state["category"] == "billing": return "billing" return "technical" builder = StateGraph(State) builder.add_node("classify", classify) builder.add_node("billing", billing) builder.add_node("technical", technical) builder.add_edge(START, "classify") builder.add_conditional_edges("classify", route_request, ["billing", "technical"]) builder.add_edge("billing", END) builder.add_edge("technical", END) graph = builder.compile() print(graph.invoke({"question": "Why was I charged twice?", "category": "", "response": ""})) # {'question': 'Why was I charged twice?', 'category': 'billing', 'response': 'This looks like a billing-related issue.'} print(graph.invoke({"question": "Why is my API returning HTTP 500?", "category": "", "response": ""})) # {'question': 'Why is my API returning HTTP 500?', 'category': 'technical', 'response': 'This looks like a technical issue.'} for update in graph.stream({"question": "Why was I charged twice?"}, stream_mode="updates"): print(update) # {'classify': {'category': 'billing'}} # {'billing': {'response': 'This looks like a billing-related issue.'}}
Path maps - a dictionary, a list, or a Literal
from typing import Literal # Router keys need not be node names - the dictionary translates them def route_by_key(state: State): return "BILLING_PATH" if state["category"] == "billing" else "TECHNICAL_PATH" # builder.add_conditional_edges( # "classify", route_by_key, # {"BILLING_PATH": "billing", "TECHNICAL_PATH": "technical"}, # ) # Or declare the destinations in the return type def route_typed(state: State) -> Literal["billing", "technical"]: return "billing" if state["category"] == "billing" else "technical" # builder.add_conditional_edges("classify", route_typed) # Both give LangGraph the full picture: # classify -.-> billing; classify -.-> technical; # Without either: classify --> __end__; - the branches are invisible
The model classifies, the graph routes
from pydantic import BaseModel from langchain_ollama import ChatOllama class Category(BaseModel): category: Literal["billing", "technical", "account"] classifier = ChatOllama(model="llama3.1", temperature=0).with_structured_output(Category) def classify_with_llm(state): result = classifier.invoke( "Classify this customer support question as billing (payments, charges, refunds, invoices), " "technical (errors, crashes, bugs, performance) or account (login, password, profile, access).\n" f"Question: {state['question']}" ) return {"category": result.category} def route(state) -> Literal["login", "billing", "technical", "account"]: if state.get("authenticated") is False: # a business rule - no model needed return "login" return state["category"] # builder.add_node("classify", classify_with_llm) # builder.add_conditional_edges("classify", route) # "I was billed twice this month" -> billing (keyword rule: technical) # "I forgot my password" -> account (keyword rule: technical) # Eight questions: llama3 8 of 8, keyword rule 3 of 8, about 0.5 s per question
Mistakes - checked
Router returns "biling" (typo), no path map Task classify ... wrote to unknown channel branch:to:biling, ignoring it. {'question': 'payment failed', 'category': 'billing'} <- no response, no error Router returns "biling", path map ["billing", "technical"] KeyError: 'biling' No add_conditional_edges after classify at all {'question': 'payment', 'category': 'billing'} <- run just ends Router returns {"category": "billing"} instead of a name ... wrote to unknown channel ..., ignoring it. <- routers cannot update state authenticated=False -> Please log in first. authenticated=True -> billing team

Tip: Stream with stream_mode="updates" to see which branch ran: the second update is keyed by the node the router chose.

Conditional edges at a glance

add_edge

Always go to this node.

builder.add_edge("billing", END)
add_conditional_edges

Let a router pick the next node.

add_conditional_edges("classify", route, [...])
Router

state -> node name (or END). Reads only.

def route(state): return "billing"
Path map (list)

The possible destinations.

["billing", "technical"]
Path map (dict)

Router key -> node.

{"BILLING_PATH": "billing"}
Literal return type

Declares destinations for drawing.

-> Literal["billing", "technical"]
END

Finish the run from a router.

return END

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Add account

“Add an account node and route "I forgot my password" to it. Use a path map.”

Make a typo

“Return "biling" from the router - once without a path map, once with. Which one would you notice?”

Draw it

“Print graph.get_graph().draw_mermaid() with and without a path map and compare the edges.”

Swap the classifier

“Replace classify with the llama3 version and run the eight questions from this lesson.”

Add a rule

“Add authenticated to the state and route unauthenticated users to a login node before anything else.”

What usually goes wrong

No path map

A misspelled route then fails silently - the run just stops - and the drawn graph hides the branches.

✗ builder.add_conditional_edges("classify", route_request)
✓ builder.add_conditional_edges("classify", route_request, ["billing", "technical"])
Forgetting the conditional edge

A router that is never registered never runs. The graph ends after classify with no error.

Doing work in the router

Routers decide; nodes work. A router cannot even update the state - put the work in the node it routes to.

✗ def route(state):
    call_billing_api(state)
    return "billing"
✓ def route(state):
    return "billing"

def billing(state):
    return {"response": call_billing_api(state)}
Keyword rules for language

"billed", "refund", "card declined" all slipped past a charge/payment rule. Let a model classify free text, constrained to known categories.

A model for a boolean

"Is the user logged in?" needs no model. Deterministic rules are cheaper, faster and testable.

Key points

  • A normal edge always goes to one node; a conditional edge lets a router choose.
  • Classifier nodes write the decision into state; routers read it and return a destination.
  • Routers only decide - they cannot update the state.
  • Always pass a path map: a typo then raises KeyError instead of silently ending the run.
  • A path map or a Literal return type also makes the drawn graph show the branches.
  • Routers can combine fields (authenticated, category) and can return END.
  • Let a model classify language - llama3 8 of 8 against keywords 3 of 8 - and keep routing in code.

Quick check before you move on

What is the difference between add_edge("A", "B") and add_conditional_edges("A", router)?
The first always goes from A to B. The second calls router(state) after A and goes wherever it returns.
What does a router return?
A destination: a node name, a key in the path map, or END.
Does the router do the business work?
No. It only decides where to go - and cannot change the state. The node it routes to does the work.
What does conditional routing usually depend on?
The current state - a category, a flag, a tool result, a count.
Why pass a path map?
So an unknown route raises an error instead of silently ending the run, and so the drawn graph shows every branch.

Quiz

  1. 1.

    The router returns "biling" and there is no path map. What happens?

  2. 2.

    Why did the keyword classifier send "I was billed twice" to technical?

  3. 3.

    What changed in the graph when llama3 replaced the keyword classifier?

  4. 4.

    Can a conditional edge create a loop?

Interview questions

What is a conditional edge in LangGraph?

An edge whose destination is chosen at run time by a routing function on the current state, from a set of possible next nodes.

Why separate classification, routing and execution?

Each is simpler and testable on its own: the classifier writes a decision into state, the router maps state to a destination, the workers do the work. Changing one - say, swapping in an LLM classifier - leaves the others alone.

When would you route with an LLM and when with code?

Use a model to interpret free text - intent, category - constrained to a fixed set of values. Use code for facts already in the state: flags, counts, thresholds. Code is cheaper, faster and deterministic.

What can go wrong with conditional edges, and how do you guard against it?

A router returning a name that is not a node. Without a path map that silently ends the run; with one it raises. Pass a path map or a Literal return type, and unit-test routers with sample states.

Can routing depend on several state fields?

Yes - for example, unauthenticated users go to login first, otherwise route by category.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...