Conditional Edges
Let the state choose the next node: router functions, add_conditional_edges, path maps - and llama3 doing the classifying.
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.
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.
"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.
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.
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
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.'}}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 invisiblefrom 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 questionRouter 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 teamTip: 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_edgeAlways go to this node.
builder.add_edge("billing", END)add_conditional_edgesLet a router pick the next node.
add_conditional_edges("classify", route, [...])Routerstate -> 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 typeDeclares destinations for drawing.
-> Literal["billing", "technical"]
ENDFinish 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 an account node and route "I forgot my password" to it. Use a path map.”
“Return "biling" from the router - once without a path map, once with. Which one would you notice?”
“Print graph.get_graph().draw_mermaid() with and without a path map and compare the edges.”
“Replace classify with the llama3 version and run the eight questions from this lesson.”
“Add authenticated to the state and route unauthenticated users to a login node before anything else.”
What usually goes wrong
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"])A router that is never registered never runs. The graph ends after classify with no error.
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)}"billed", "refund", "card declined" all slipped past a charge/payment rule. Let a model classify free text, constrained to known categories.
"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
Quiz
- 1.
The router returns "biling" and there is no path map. What happens?
- 2.
Why did the keyword classifier send "I was billed twice" to technical?
- 3.
What changed in the graph when llama3 replaced the keyword classifier?
- 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...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned