Conditional Edges
Let the graph choose the next step. A small router function reads the state and names the next node. Build a support desk that sends billing, technical and account questions to the right team - then let llama3 do the understanding.
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 the next node
- Connect it with add_conditional_edges and a path map
- Explain why the path map is important
- Route on more than one field, and finish the run early with END
- Use an LLM to classify a question, and plain code to route it
The idea, in plain English
In Lessons 3.1 and 3.2 every edge was fixed: after node A, always node B. Real workflows need choices. A question about money should go to the billing team. A question about an error should go to the technical team.
A conditional edge makes this possible. After a node finishes, LangGraph calls a small function - the router. The router looks at the state and answers one question: "Which node is next?" LangGraph then runs that node, and only that node.
In this lesson we build a support desk step by step, break it on purpose to see the common mistakes, and finally replace a simple keyword check with llama3. Everything was run with LangGraph 1.2.14 and llama3 through Ollama.
Worked example: A support desk: a question comes in, a classifier decides what kind it is, and a router sends it to the billing, technical or account team.
1 - Keyword classifier: wrong team
The keyword check looks for the words "charge" or "payment". The word "billed" is neither, so it writes category = technical. The router does its job correctly - and sends the question to the wrong team.
"I was billed twice this month" goes through the support desk - first with a keyword classifier, then with llama3. Real runs.
Words you will see in this lesson
A few words used again and again in this lesson.
NodeOne step of the graph: a Python function that reads the state and returns changes.EdgeA line between two nodes: "after this node, go to that node".Normal edgeAlways goes to the same next node. Made with add_edge.Conditional edgeThe next node is chosen while the graph runs. Made with add_conditional_edges.RouterThe small function that chooses: it reads the state and returns a node name.Path mapThe list of nodes the router is allowed to choose from.ClassifierA node that decides what kind of input this is, and writes it into the state.ENDA special name meaning "the run is finished".An everyday example: a hospital reception
You walk into a hospital. Three people help you, and each one has one job:
- The receptionist listens to your problem and writes the department on your token: "Cardiology". (This is the classifier node.)
- The sign board at the corridor reads the token and points you to a door. It does not treat you and does not change your token. (This is the router.)
- The doctor behind the door does the real work. (These are the worker nodes.)
Normal edge vs conditional edge
There are two ways to connect a node to the next one:
- add_edge("classify", "billing") - after classify, ALWAYS go to billing. No choice.
- add_conditional_edges("classify", route_request, ["billing", "technical"]) - after classify, call route_request(state), and go to the node it returns: billing OR technical.
- Only the chosen node runs. A billing question never costs a call to the technical team.
- The router can use anything in the state: a category, a yes/no flag, a tool result, a retry count.
Three jobs - keep them apart
The most important idea of this lesson is that deciding and doing are separate jobs. Give each job its own piece of code:
- Classify (a node): work out WHAT the question is, and write it into the state - category = "billing".
- Route (the router function): read the state and answer only WHERE to go next - return "billing". No other work.
- Work (worker nodes): do the actual job - write the reply for a billing question.
Tip: Because each job is separate, you can change one without touching the others. Later in this lesson we replace the classifier with llama3, and the router and the workers stay exactly the same.
Example 1, step 1 - the state and the classifier
The state has three fields: the question, the category (filled in by classify), and the response (filled in by a worker).
classify is a normal node. For now it uses a simple keyword rule: if the question contains "charge" or "payment", the category is billing; otherwise technical. It returns a dict, and LangGraph writes that into the state.
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
question: str
category: str
response: str
def classify(state: State): # job 1: decide WHAT the question is
question = state["question"].lower()
if "charge" in question or "payment" in question:
return {"category": "billing"} # write the answer into the state
return {"category": "technical"}Example 1, step 2 - the workers and the router
billing and technical are the worker nodes. Each one writes a response.
route_request is the router. Look at how small it is:
- It receives the state - the same state the nodes see.
- It reads one field: state["category"].
- It returns a string: the NAME of the next node, "billing" or "technical".
- It does not return a dict, and it does not change the state.
def billing(state: State): # job 3: do the work
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): # job 2: decide WHERE to go next
if state["category"] == "billing":
return "billing" # the name of the next node
return "technical"Example 1, step 3 - connect everything
Now we build the graph. Read the edges from top to bottom:
- START -> classify: a normal edge. Every run starts with classify.
- classify -> ?: a conditional edge. After classify, LangGraph calls route_request and goes to the node it names.
- The third argument, ["billing", "technical"], is the path map: the only nodes the router may choose.
- billing -> END and technical -> END: normal edges. After a worker, the run is finished.
builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_node("billing", billing)
builder.add_node("technical", technical)
builder.add_edge(START, "classify") # normal edge: always classify first
builder.add_conditional_edges("classify", route_request, ["billing", "technical"]) # the router picks
builder.add_edge("billing", END) # normal edges: then finish
builder.add_edge("technical", END)
graph = builder.compile()
print(graph.invoke({"question": "Why was I charged twice?", "category": "", "response": ""}))
print(graph.invoke({"question": "Why is my API returning HTTP 500?", "category": "", "response": ""})){'question': 'Why was I charged twice?', 'category': 'billing', 'response': 'This looks like a billing-related issue.'}
{'question': 'Why is my API returning HTTP 500?', 'category': 'technical', 'response': 'This looks like a technical issue.'}Watch the choice happen
stream_mode="updates" prints what each node changed, in order. It shows the route clearly:
- First update: classify wrote category = billing.
- Second update: the billing node ran - because the router chose it.
- There is no update from technical. It never ran.
for update in graph.stream({"question": "Why was I charged twice?"}, stream_mode="updates"):
print(update)
# Output
# {'classify': {'category': 'billing'}}
# {'billing': {'response': 'This looks like a billing-related issue.'}}The path map - three ways to write it
The path map tells LangGraph which nodes the router can choose. You can give it in three ways. All three worked the same in our test:
- A list of node names: ["billing", "technical"]. The router returns a node name. This is the simplest.
- A dict: {"BILLING_PATH": "billing", "TECHNICAL_PATH": "technical"}. The router returns a key, and the dict turns it into a node name.
- A Literal return type on the router: -> Literal["billing", "technical"]. Then you do not pass a third argument - LangGraph reads the allowed names from the type.
from typing import Literal
# A dict: the router returns a key, the dict gives the node
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"},
)
# A Literal: the return type lists the allowed nodes - no third argument
def route_typed(state: State) -> Literal["billing", "technical"]:
return "billing" if state["category"] == "billing" else "technical"
builder.add_conditional_edges("classify", route_typed)Why you should always give a path map
The path map looks optional - the code runs without it. But we tested a small typo: the router returned "biling" (one l) instead of "billing".
- Without a path map: NO error. LangGraph only wrote a warning to the log, ran nothing after classify, and returned a state with no response. In a real app, the customer would simply get no answer.
- With a list path map: KeyError: ‘biling’. The mistake is reported at once.
- With a Literal return type: also KeyError: ‘biling’.
- The drawing also changes. Without a path map, get_graph() drew classify --> __end__, as if the teams did not exist. With a path map (or Literal) it drew classify -.-> billing and classify -.-> technical.
1. typo, no path map
LOG: Task classify with path ('__pregel_pull', 'classify') wrote to unknown channel branch:to:biling, ignoring it.
result: {'question': 'payment failed', 'category': 'billing'} <- no response, no error
2. typo, with path map ["billing", "technical"]
ERROR: KeyError: 'biling'
3. typo, with -> Literal["billing", "technical"]
ERROR: KeyError: 'biling'no map ['classify(classify)', 'classify --> __end__;']
list map ['classify(classify)', 'classify -.-> billing;', 'classify -.-> technical;']
dict map ['classify(classify)', 'classify -. BILLING_PATH .-> billing;', 'classify -. TECHNICAL_PATH .-> technical;']
Literal ['classify(classify)', 'classify -.-> billing;', 'classify -.-> technical;']Watch out: A wrong node name without a path map fails silently. Always pass the path map (a simple list is enough) or give the router a Literal return type.
Two more silent mistakes
These two also gave no error at all - only a missing response:
- Forgetting add_conditional_edges: the router function exists, but nobody calls it. The run ended after classify.
- A router that returns a dict like {"category": "billing"} instead of a node name: LangGraph ignored it with the warning "wrote to unknown channel". A router cannot change the state - only nodes can.
3. no conditional edge at all
result: {'question': 'payment failed', 'category': 'billing'} <- run just ends
4. router returns a dict
LOG: Task classify with path ('__pregel_pull', 'classify') wrote to unknown channel branch:to:{'category': 'billing'}, ignoring it.
result: {'question': 'payment failed', 'category': 'billing'} <- ignoredTip: A router is a plain function of the state, so it is the easiest part of a graph to test: make a small state dict, call the router, and check the name it returns.
Example 2 - several rules, and finishing early
A router can check more than one field. And a conditional edge can also start right at START. In this version:
- check_question runs first, from START. If the question is empty, it returns END - the run finishes at once, and nothing else runs.
- route checks authenticated FIRST. A user who is not logged in goes to the login node, whatever the question is.
- Only logged-in users are routed by category.
- Both routers have a Literal return type, so no path map argument is needed. Note "__end__" - that is the name END stands for.
from typing import Literal, TypedDict
from langgraph.graph import END, START, StateGraph
class S(TypedDict, total=False):
question: str
authenticated: bool
category: str
response: str
def classify(state: S):
q = state["question"].lower()
return {"category": "billing" if ("charge" in q or "payment" in q) else "technical"}
def login(state: S): return {"response": "Please log in first."}
def billing(state: S): return {"response": "billing team"}
def technical(state: S): return {"response": "technical team"}
def check_question(state: S) -> Literal["classify", "__end__"]:
if not state["question"].strip(): # nothing to do -> finish now
return END
return "classify"
def route(state: S) -> Literal["login", "billing", "technical"]:
if not state.get("authenticated"): # a business rule - checked first
return "login"
return state["category"]
b = StateGraph(S)
for name, fn in [("classify", classify), ("login", login), ("billing", billing), ("technical", technical)]:
b.add_node(name, fn)
b.add_conditional_edges(START, check_question)
b.add_conditional_edges("classify", route)
for name in ["login", "billing", "technical"]:
b.add_edge(name, END)
g = b.compile()
for inp in [{"question": "My payment failed", "authenticated": False},
{"question": "My payment failed", "authenticated": True},
{"question": "The app crashes", "authenticated": True},
{"question": " ", "authenticated": True}]:
print(inp, "->", g.invoke(inp)){'question': 'My payment failed', 'authenticated': False} -> {'question': 'My payment failed', 'authenticated': False, 'category': 'billing', 'response': 'Please log in first.'}
{'question': 'My payment failed', 'authenticated': True} -> {'question': 'My payment failed', 'authenticated': True, 'category': 'billing', 'response': 'billing team'}
{'question': 'The app crashes', 'authenticated': True} -> {'question': 'The app crashes', 'authenticated': True, 'category': 'technical', 'response': 'technical team'}
{'question': ' ', 'authenticated': True} -> {'question': ' ', 'authenticated': True}Reading Example 2
- Not logged in + a payment question -> "Please log in first." The login rule won, even though classify said billing.
- Logged in + a payment question -> billing team.
- Logged in + a crash -> technical team.
- An empty question -> the state came back unchanged: no category, no response. check_question returned END before classify could run.
- A conditional edge can also point back to a node that already ran. That makes a loop - the topic of Lesson 3.4.
Example 3 - the model classifies, the graph routes
The keyword rule is the weak part of our support desk. People describe the same problem with many different words. So we replace only the classify node with llama3:
- Category is a Pydantic model whose field is Literal["billing", "technical", "account"]. with_structured_output(Category) forces llama3 to answer with exactly one of these three words.
- This is what keeps the graph safe: the model can never invent a category that the router does not know.
- classify_with_llm returns {"category": ...} - exactly like the old classify. The router and the workers do not change at all.
- temperature=0 makes the answers as repeatable as possible.
from typing import Literal
from langchain_ollama import ChatOllama
from pydantic import BaseModel
class Category(BaseModel):
category: Literal["billing", "technical", "account"] # the model may ONLY answer one of these
classifier = ChatOllama(model="llama3", 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} # same shape as the old classify
# In the graph, only this line changes:
# builder.add_node("classify", classify_with_llm)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 month keyword: technical ✗ llama3: billing ✓
Can I get a refund? keyword: technical ✗ llama3: billing ✓
My card was declined keyword: technical ✗ llama3: billing ✓
I forgot my password keyword: technical ✗ llama3: account ✓
I cannot log into my account keyword: technical ✗ llama3: account ✓
The app crashes when I upload a file keyword: technical ✓ llama3: technical ✓
keyword 3/8, llama3 8/8, 0.55 s per questionWhen to use a model, and when plain code
The keyword rule got 3 of 8 right. "billed", "refund" and "card declined" mean billing, but they do not contain "charge" or "payment". And it has no account category at all. llama3 got all 8 right, in about half a second each.
But a model is not always the right tool. A simple rule:
- Use a model when the input is free text that must be understood: what does the customer want?
- Use plain code when the answer is already in the state: is the user logged in? Is the retry count above 3? Is the question empty?
- Plain code is free, instant and always gives the same answer. A model costs time (and often money) and can be wrong.
- Even with a model, keep the router as plain code. The model fills in category; the router only reads it.
Why not one node with if / else?
You could write one big node: if billing: ... elif technical: ... That works for two cases. As the app grows, conditional edges are better:
- You can SEE the decisions in the graph drawing, instead of hiding them inside one function.
- Each team is its own node, so you can test, stream and trace it on its own.
- Adding a new team is one new node and one new name in the path map.
The recipe
Every conditional edge you write follows the same four steps:
- 1. A node writes the decision into the state (category = "billing").
- 2. A router function reads the state and returns the name of the next node - nothing else.
- 3. add_conditional_edges("node", router, ["option1", "option2"]) - always with the path map, or a Literal return type.
- 4. Every option is a node that does the work, followed by its own edge.
Conditional edges at a glance
add_edgeAlways go to this node.
builder.add_edge("billing", END)add_conditional_edgesLet a router choose the next node.
add_conditional_edges("classify", route, ["billing", "technical"])RouterReads the state, returns a node name. Changes nothing.
def route(state): return "billing"
Path map (list)The nodes the router may choose.
["billing", "technical"]
Path map (dict)Router key -> node.
{"BILLING_PATH": "billing"}Literal return typeLists the allowed nodes; no third argument needed.
-> Literal["billing", "technical"]
ENDFinish the run now.
return END
From STARTChoose the very first node.
add_conditional_edges(START, check_question)
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. Remember to add it to the 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 lines after classify.”
“Replace classify with classify_with_llm and run the eight questions from this lesson.”
“Call route_request({"category": "billing"}) directly, without the graph. What does it return?”
What usually goes wrong
A wrong node name then fails silently - the run just stops - and the drawing hides the branches.
✗ builder.add_conditional_edges("classify", route_request)✓ builder.add_conditional_edges("classify", route_request, ["billing", "technical"])A router that is never connected never runs. The graph ends after classify, with no error.
Routers decide; nodes work. A router cannot even change the state - put the work in the node it chooses.
✗ def route(state):
call_billing_api(state)
return "billing"✓ def route(state):
return "billing"
def billing(state):
return {"response": call_billing_api(state)}A router returns a node name (a string), not {"category": ...}. The dict was ignored with a warning.
"billed", "refund" and "card declined" all slipped past a charge/payment rule. Let a model classify free text - limited to known categories.
"Is the user logged in?" needs no model. Plain code is cheaper, faster and always gives the same answer.
Key points
- A normal edge always goes to one node; a conditional edge lets a router choose.
- Keep three jobs apart: a node classifies, a router chooses, worker nodes do the work.
- A router reads the state and returns a node name - it cannot change the state.
- Always pass a path map (or use a Literal return type): a typo then raises KeyError instead of silently stopping.
- Routers can check several fields, start from START, and return END to finish early.
- Use a model to understand free text (llama3: 8 of 8, keywords: 3 of 8); use plain code for facts already in the state.
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.
A user who is not logged in asks about a payment. Where does Example 2 send them, and why?
- 5.
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 a path map or a Literal return type it raises. 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...