LangGraph Nodes, Edges & State: Core Concepts Explained
Learn how nodes, edges, conditional edges, and the State object work together in LangGraph, with hands-on code you can run right now.
So you built your first LangGraph graph. It ran. But do you really know what add_node, add_edge, and that TypedDict class do behind the scenes?
Here’s what I see all the time. People copy-paste LangGraph code. It works. Then they try to change something, add a branch, tweak the state, drop in a new step. And they get stuck. Why? They never built a clear picture of how the pieces fit.
I wrote this guide to fix that. By the end, you’ll know what nodes, edges, and state do. You’ll build graphs from scratch, step by step. Not by copying. By truly getting it.
Prerequisites
- Python version: 3.10+
- Required libraries: langgraph (0.3+)
- Install:
pip install langgraph - Previous article:LangGraph Installation, Setup, and Your First Graph, you should be comfortable creating and running a basic graph
- Time to complete: ~25 minutes
What Are Nodes in LangGraph?
Think about making breakfast. You crack the eggs. That’s one task. You whisk them. That’s a second task. You pour the mix into a pan. Task three. Each task takes something in and gives something back.
That’s how LangGraph nodes work. A node is a Python function. It does one job. It reads the current state, does its work, and hands back the changes.
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class SimpleState(TypedDict):
message: str
def greet(state: SimpleState) -> dict:
return {"message": "Hello from the node!"}
graph = StateGraph(SimpleState)
graph.add_node("greet", greet)
graph.add_edge(START, "greet")
graph.add_edge("greet", END)
app = graph.compile()
result = app.invoke({"message": ""})
print(result)
#> {'message': 'Hello from the node!'}
See the greet function? That’s the node. It got the state (with a blank message), then sent back a new value. LangGraph took care of the rest, it called the function, fed it the state, and grabbed the result.
Here are three things to note. First, a node is just a plain Python function. No special setup. Second, it takes state in and gives back a dict with updates. Third, you hook it up with add_node("name", function).
What Can a Node Actually Do?
Whatever Python can do. Call an LLM. Hit a database. Change some text. Fire off an API call. Crunch numbers. There’s just one rule: take state in, send state updates back.
def transform_message(state: SimpleState) -> dict:
original = state["message"]
return {"message": original.upper() + " (transformed)"}
graph2 = StateGraph(SimpleState)
graph2.add_node("greet", greet)
graph2.add_node("transform", transform_message)
graph2.add_edge(START, "greet")
graph2.add_edge("greet", "transform")
graph2.add_edge("transform", END)
app2 = graph2.compile()
result2 = app2.invoke({"message": ""})
print(result2)
#> {'message': 'HELLO FROM THE NODE! (transformed)'}
First, greet ran and set the message. Then transform grabbed it, made it uppercase, and tacked on a suffix. One node’s output became the next node’s input.
What Are Edges, And Why Do They Matter?
Nodes on their own? Just loose functions. Nothing links them. Nothing runs.
Edges are the wires. They tell LangGraph the order: “When this node is done, run that one next.” The line graph.add_edge("greet", "transform") means: go to transform right after greet wraps up.
START and END, The Built-In Boundaries
Every graph comes with two special nodes: START and END. You import them from langgraph.graph.
START is where things kick off. Link it to your first node. When you call app.invoke(), LangGraph follows that edge to find what runs first.
END is the stop sign. Once a node links to END, the graph hands back the final state.
from langgraph.graph import START, END
# The minimum graph structure:
# START -> your_node -> END
START, and your graph compiles but does nothing when invoked. Forget the edge to END, and LangGraph raises an error, it detects a dead end with no exit path.Chaining Multiple Nodes
Need three nodes in a row? Add three edges. Each edge is one link in the chain.
Here’s a three-step data pipeline. Each step changes the text field and logs its name to steps_completed:
class PipelineState(TypedDict):
text: str
steps_completed: list[str]
def step_one(state: PipelineState) -> dict:
return {
"text": "raw data",
"steps_completed": ["step_one"]
}
def step_two(state: PipelineState) -> dict:
updated = state["text"] + " -> cleaned"
steps = state["steps_completed"] + ["step_two"]
return {"text": updated, "steps_completed": steps}
def step_three(state: PipelineState) -> dict:
updated = state["text"] + " -> analyzed"
steps = state["steps_completed"] + ["step_three"]
return {"text": updated, "steps_completed": steps}
Now wire them up and run:
pipeline = StateGraph(PipelineState)
pipeline.add_node("step_one", step_one)
pipeline.add_node("step_two", step_two)
pipeline.add_node("step_three", step_three)
pipeline.add_edge(START, "step_one")
pipeline.add_edge("step_one", "step_two")
pipeline.add_edge("step_two", "step_three")
pipeline.add_edge("step_three", END)
app3 = pipeline.compile()
result3 = app3.invoke({"text": "", "steps_completed": []})
print(f"Final text: {result3['text']}")
print(f"Steps: {result3['steps_completed']}")
#> Final text: raw data -> cleaned -> analyzed
#> Steps: ['step_one', 'step_two', 'step_three']
Each add_edge call linked one node to the next. The state moved through them in order, picking up changes at each stop.
How Does the State Object Work as Your Graph’s Shared Memory?
Every beginner asks the same thing: “How does Node B find out what Node A did?” The short answer: state.
State is a shared data bucket. Every node reads from it. Every node writes to it. It’s the glue. It’s how nodes talk to each other.
Defining State with TypedDict
You set up state with Python’s TypedDict. This tells LangGraph what fields your data has:
Three fields here: user_input, response, turn_count. Every node in the graph can see and change any of them.
Why use TypedDict instead of a plain dict? Two wins. Your editor gives you autocomplete and flags typos. Also, LangGraph checks your updates at runtime using the type hints.
How Does State Flow Through the Graph?
Most guides skip this part. I want to walk you through each step. Once you see the flow, LangGraph clicks.
This graph takes a number, adds 5, doubles it, then takes away 3. Each node logs what it did:
Build it and start with a value of 10:
counter_graph = StateGraph(CounterState)
counter_graph.add_node("add_five", add_five)
counter_graph.add_node("double_it", double_it)
counter_graph.add_node("subtract_three", subtract_three)
counter_graph.add_edge(START, "add_five")
counter_graph.add_edge("add_five", "double_it")
counter_graph.add_edge("double_it", "subtract_three")
counter_graph.add_edge("subtract_three", END)
counter_app = counter_graph.compile()
result4 = counter_app.invoke({"value": 10, "history": []})
print(f"Final value: {result4['value']}")
print("\nExecution trace:")
for entry in result4["history"]:
print(f" {entry}")
#> Final value: 27
#> Execution trace:
#> add_five: 10 -> 15
#> double_it: 15 -> 30
#> subtract_three: 30 -> 27
Let me break it down step by step:
| Step | Node | Input value | What it does | Output value |
|---|---|---|---|---|
| 1 | add_five | 10 | 10 + 5 | 15 |
| 2 | double_it | 15 | 15 * 2 | 30 |
| 3 | subtract_three | 30 | 30 – 3 | 27 |
Each node got the state from the node before it. The value hopped from node to node, changing at each step.
{“value”: 15}, LangGraph merges that into the existing state. Fields the node doesn’t return stay unchanged.Partial State Updates, Do You Need to Return Every Field?
No. And this trips people up early on. Just return what you want to change. Leave the rest alone:
profile_graph = StateGraph(ProfileState)
profile_graph.add_node("set_name", set_name)
profile_graph.add_node("set_email", set_email)
profile_graph.add_node("verify", verify)
profile_graph.add_edge(START, "set_name")
profile_graph.add_edge("set_name", "set_email")
profile_graph.add_edge("set_email", "verify")
profile_graph.add_edge("verify", END)
profile_app = profile_graph.compile()
result5 = profile_app.invoke({"name": "", "email": "", "verified": False})
print(result5)
#> {'name': 'Alice', 'email': 'alice@example.com', 'verified': True}
Each node changed just one field. LangGraph merged all three updates on its own. The final state shows every change from the whole chain.
How Do Conditional Edges Help Your Graph Make Choices?
Normal edges are like train tracks. Fixed. Set in stone. Conditional edges are like highway ramps, the route depends on what’s going on right now.
This is where LangGraph gets really useful. Your graph looks at the current state and picks which node goes next. The same graph can take a different path each time you run it.
Your First Conditional Edge
You write a routing function. It checks the state and returns the name of the next node. LangGraph calls this function after the source node finishes. Then it follows the path.
Here’s a content sorter. The classify node reads the text and sets a category based on what it finds:
Now the routing function reads the category and picks a handler:
Use add_conditional_edges to wire it up. Pass in the source node, the routing function, and a mapping of return values to node names:
content_graph = StateGraph(ContentState)
content_graph.add_node("classify", classify)
content_graph.add_node("handle_urgent", handle_urgent)
content_graph.add_node("handle_question", handle_question)
content_graph.add_node("handle_general", handle_general)
content_graph.add_edge(START, "classify")
content_graph.add_conditional_edges(
"classify",
route_content,
{
"handle_urgent": "handle_urgent",
"handle_question": "handle_question",
"handle_general": "handle_general",
}
)
content_graph.add_edge("handle_urgent", END)
content_graph.add_edge("handle_question", END)
content_graph.add_edge("handle_general", END)
content_app = content_graph.compile()
Let’s test all three paths:
test_inputs = [
{"text": "Emergency! Server is down!", "category": "", "result": ""},
{"text": "What is LangGraph?", "category": "", "result": ""},
{"text": "Weekly status update", "category": "", "result": ""},
]
for inp in test_inputs:
output = content_app.invoke(inp)
print(f"Input: '{inp['text']}'")
print(f" Category: {output['category']}, Result: {output['result']}")
#> Input: 'Emergency! Server is down!'
#> Category: urgent, Result: URGENT: Escalated 'Emergency! Server is down!'
#> Input: 'What is LangGraph?'
#> Category: question, Result: Q&A: Processing question 'What is LangGraph?'
#> Input: 'Weekly status update'
#> Category: general, Result: GENERAL: Filed 'Weekly status update'
One graph. Three paths. The routing function looked at state["category"] and chose the right handler every time.
ValueError.Breaking Down add_conditional_edges
This method takes three parts. Here’s what each one does:
content_graph.add_conditional_edges(
"classify", # Source node — branch FROM here
route_content, # Routing function — decides WHERE next
{ # Mapping — translates return values to node names
"handle_urgent": "handle_urgent",
"handle_question": "handle_question",
"handle_general": "handle_general",
}
)
The mapping is optional. If your routing function already returns real node names, leave it out:
# No mapping needed — route_content already returns node names
content_graph.add_conditional_edges("classify", route_content)
I still like to include the mapping. It shows all the paths in one spot. Makes the graph easier to read at a glance.
How Can You See Your Graph’s Shape?
Don’t guess if your graph is wired right. Look at it.
LangGraph’s get_graph() method gives you the graph shape. Then draw_mermaid() turns it into a diagram you can view in any Mermaid tool (GitHub, VS Code, mermaid.live):
mermaid_code = content_app.get_graph().draw_mermaid()
print(mermaid_code)
#> %%{init: {'flowchart': {'curve': 'linear'}}}%%
#> graph TD;
#> __start__([<p>__start__</p>])
#> classify(classify)
#> handle_urgent(handle_urgent)
#> handle_question(handle_question)
#> handle_general(handle_general)
#> __end__([<p>__end__</p>])
#> __start__ --> classify;
#> classify -.-> handle_urgent;
#> classify -.-> handle_question;
#> classify -.-> handle_general;
#> handle_urgent --> __end__;
#> handle_question --> __end__;
#> handle_general --> __end__;
Solid arrows (-->) mean normal edges. Dashed arrows (.->) mean conditional edges. Paste this into a Mermaid tool and you’ll see the full layout.
How Do Flowcharts Map to LangGraph?
If you’ve drawn a flowchart before, you already get the LangGraph model. Here’s a side-by-side look:
| Flowchart Element | LangGraph Equivalent | Code |
|---|---|---|
| Process box (rectangle) | Node | add_node("name", func) |
| Arrow | Edge | add_edge("a", "b") |
| Diamond (decision) | Conditional edge | add_conditional_edges(...) |
| Start oval | START | add_edge(START, "first") |
| End oval | END | add_edge("last", END) |
| Data on arrows | State (TypedDict) | class MyState(TypedDict) |
The big gap? A flowchart sits on paper. A LangGraph graph runs as code. Your boxes become functions. Your arrows become edges. Your diamonds become conditional branches.
How Does It All Come Together?, Order Processing System
Let’s mix nodes, edges, conditional edges, and state into something you’d see in the real world. This pipeline checks orders, looks up stock, works out the price, and then approves or rejects.
The state holds everything the pipeline needs:
Each step is its own node. First, the validator checks if the order makes sense:
Next, the stock checker looks up what’s on hand. In a real app, this would call a database:
The price step adds a 10% bulk cut for orders of 10 or more:
Then come the approve and reject handlers:
The routing function checks both the validation flag and the stock flag:
Now wire it all up, a straight line through validate, stock, and price, then a fork for the final call:
order_graph = StateGraph(OrderState)
order_graph.add_node("validate", validate_order)
order_graph.add_node("check_inventory", check_inventory)
order_graph.add_node("calculate_price", calculate_price)
order_graph.add_node("approve", approve_order)
order_graph.add_node("reject", reject_order)
order_graph.add_edge(START, "validate")
order_graph.add_edge("validate", "check_inventory")
order_graph.add_edge("check_inventory", "calculate_price")
order_graph.add_conditional_edges(
"calculate_price",
should_approve,
{"approve": "approve", "reject": "reject"}
)
order_graph.add_edge("approve", END)
order_graph.add_edge("reject", END)
order_app = order_graph.compile()
Try a good order first, 5 widgets at $29.99 each:
good_order = {
"order_id": "ORD-001", "item": "widget",
"quantity": 5, "price_per_unit": 29.99,
"total": 0.0, "in_stock": False,
"is_valid": False, "status": "", "log": []
}
result_good = order_app.invoke(good_order)
print(f"Status: {result_good['status']}")
print(f"Total: ${result_good['total']:.2f}")
for entry in result_good["log"]:
print(f" {entry}")
#> Status: approved
#> Total: $149.95
#> validate: Validated OK
#> inventory: widget has 100 units, need 5
#> pricing: total=$149.95
#> APPROVED: Order ORD-001
Now try an item with zero stock:
bad_order = {
"order_id": "ORD-002", "item": "doohickey",
"quantity": 5, "price_per_unit": 9.99,
"total": 0.0, "in_stock": False,
"is_valid": False, "status": "", "log": []
}
result_bad = order_app.invoke(bad_order)
print(f"Status: {result_bad['status']}")
for entry in result_bad["log"]:
print(f" {entry}")
#> Status: rejected
#> validate: Validated OK
#> inventory: doohickey has 0 units, need 5
#> pricing: total=$49.95
#> REJECTED: Order ORD-002
Same graph. Different result. The routing function saw in_stock=False and sent the order to the reject path.
What Are the Most Common Mistakes, And How Do You Fix Them?
Mistake 1: Orphan Nodes, Added but Never Linked
# WRONG — orphan node never executes
graph = StateGraph(SimpleState)
graph.add_node("greet", greet)
graph.add_node("orphan", transform_message) # No edges!
graph.add_edge(START, "greet")
graph.add_edge("greet", END)
LangGraph won’t flag this when you compile. The orphan node sits in the graph but never runs. Always check with get_graph() that every node has edges going in and out.
Mistake 2: Sending Back Keys That Don’t Match the State
LangGraph throws an InvalidUpdateError. The key counter doesn’t exist in the TypedDict. Make sure every key you return lines up with a field in your state.
Mistake 3: Routing to a Node That Doesn’t Exist
The router said "process_data". The node was called "process". LangGraph throws a ValueError. Check that your routing return values match the exact names from add_node.
get_graph(), (2) match return dict keys to TypedDict fields, (3) confirm routing return values match node names.Quick Check, Can You Predict the Output?
Look at this graph. What will x be at the end?
class QuizState(TypedDict):
x: int
def add_ten(state: QuizState) -> dict:
return {"x": state["x"] + 10}
def halve(state: QuizState) -> dict:
return {"x": state["x"] // 2}
quiz_graph = StateGraph(QuizState)
quiz_graph.add_node("add_ten", add_ten)
quiz_graph.add_node("halve", halve)
quiz_graph.add_edge(START, "add_ten")
quiz_graph.add_edge("add_ten", "halve")
quiz_graph.add_edge("halve", END)
quiz_app = quiz_graph.compile()
# What does quiz_app.invoke({"x": 6}) return?
Here’s how it works. x starts at 6. add_ten brings it to 16. halve cuts it to 8. So the answer is {"x": 8}.
result_quiz = quiz_app.invoke({"x": 6})
print(result_quiz)
#> {'x': 8}
Did you get it right? Great, you understand state flow. Got it wrong? Go back and re-read the trace table in the “How Does State Flow” section.
Exercise 1: Build a Temperature Classifier
Instructions: Build a LangGraph graph that classifies a temperature reading as “cold” (below 15), “moderate” (15-30), or “hot” (above 30). Create a classify node that sets the label, three handler nodes that each set an appropriate action string, and a routing function that directs traffic. The TempState TypedDict is provided.
# Starter code
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class TempState(TypedDict):
temperature: int
label: str
action: str
# TODO: Write the classify node
def classify(state: TempState) -> dict:
pass
# TODO: Write handler nodes
def handle_cold(state: TempState) -> dict:
pass
def handle_moderate(state: TempState) -> dict:
pass
def handle_hot(state: TempState) -> dict:
pass
# TODO: Write routing function and build graph
Test your solution:
# Should print: cold / Turn on heater
result = app.invoke({"temperature": 5, "label": "", "action": ""})
print(result["label"], "/", result["action"])
# Should print: hot / Turn on AC
result = app.invoke({"temperature": 35, "label": "", "action": ""})
print(result["label"], "/", result["action"])
Hints:
1. The classify node checks state["temperature"] against 15 and 30, then returns {"label": "cold"}, {"label": "moderate"}, or {"label": "hot"}.
2. The routing function reads state["label"] and returns the matching handler name (e.g., "handle_cold"). Wire it with add_conditional_edges after the classify node.
Solution
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class TempState(TypedDict):
temperature: int
label: str
action: str
def classify(state: TempState) -> dict:
temp = state["temperature"]
if temp < 15:
return {"label": "cold"}
elif temp <= 30:
return {"label": "moderate"}
else:
return {"label": "hot"}
def handle_cold(state: TempState) -> dict:
return {"action": "Turn on heater"}
def handle_moderate(state: TempState) -> dict:
return {"action": "Maintain current settings"}
def handle_hot(state: TempState) -> dict:
return {"action": "Turn on AC"}
def route_temp(state: TempState) -> str:
return {
"cold": "handle_cold",
"moderate": "handle_moderate",
"hot": "handle_hot"
}[state["label"]]
graph = StateGraph(TempState)
graph.add_node("classify", classify)
graph.add_node("handle_cold", handle_cold)
graph.add_node("handle_moderate", handle_moderate)
graph.add_node("handle_hot", handle_hot)
graph.add_edge(START, "classify")
graph.add_conditional_edges("classify", route_temp)
graph.add_edge("handle_cold", END)
graph.add_edge("handle_moderate", END)
graph.add_edge("handle_hot", END)
app = graph.compile()
for temp in [5, 22, 35]:
r = app.invoke({"temperature": temp, "label": "", "action": ""})
print(f"Temp: {temp} -> {r['label']} -> {r['action']}")
#> Temp: 5 -> cold -> Turn on heater
#> Temp: 22 -> moderate -> Maintain current settings
#> Temp: 35 -> hot -> Turn on AC
The classify node checks thresholds and sets the label. The routing function maps labels to handler names. Each handler sets a specific action. All three handlers connect to END.
Exercise 2: Build a Text Processing Pipeline
Instructions: Build a three-node pipeline that: (1) clean, strips whitespace and lowercases the text, (2) count_words, counts words and stores the count, (3) summarize, creates a summary string. Each node should append its name to the steps list.
# Starter code
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class TextState(TypedDict):
text: str
word_count: int
summary: str
steps: list[str]
# TODO: Write clean, count_words, summarize nodes
# TODO: Build the graph
Test: app.invoke({"text": " Hello World From Python ", ...}) should produce text="hello world from python", word_count=4, summary="Processed: 4 words", steps=['clean', 'count_words', 'summarize'].
Hints:
1. Use state["text"].strip().lower() in the clean node. Return both the cleaned text and the updated steps list.
2. Use len(state["text"].split()) for word counting. Append each node’s name to steps via state["steps"] + ["node_name"].
Solution
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class TextState(TypedDict):
text: str
word_count: int
summary: str
steps: list[str]
def clean(state: TextState) -> dict:
return {
"text": state["text"].strip().lower(),
"steps": state["steps"] + ["clean"]
}
def count_words(state: TextState) -> dict:
return {
"word_count": len(state["text"].split()),
"steps": state["steps"] + ["count_words"]
}
def summarize(state: TextState) -> dict:
return {
"summary": f"Processed: {state['word_count']} words",
"steps": state["steps"] + ["summarize"]
}
graph = StateGraph(TextState)
graph.add_node("clean", clean)
graph.add_node("count_words", count_words)
graph.add_node("summarize", summarize)
graph.add_edge(START, "clean")
graph.add_edge("clean", "count_words")
graph.add_edge("count_words", "summarize")
graph.add_edge("summarize", END)
app = graph.compile()
result = app.invoke({
"text": " Hello World From Python ",
"word_count": 0, "summary": "", "steps": []
})
print(result)
#> {'text': 'hello world from python', 'word_count': 4, 'summary': 'Processed: 4 words', 'steps': ['clean', 'count_words', 'summarize']}
Each node handles one transformation and logs its name. The linear graph structure ensures they run in order.
Summary
You now know the three core parts of every LangGraph app:
Nodes are Python functions. Each one reads state, does work, and sends back updates. You add them with graph.add_node("name", function).
Edges link nodes and set the run order. Use add_edge for fixed paths. Use add_conditional_edges when you need the graph to pick a path based on state.
State is a TypedDict that acts as shared memory for the whole graph. Every node reads from it. Every node writes to it. You only need to return the fields you change, LangGraph merges the rest.
The built-in START and END nodes mark where the graph begins and ends. Every graph needs at least one path from START to END.
Practice exercise: Build a quiz grading system. The state holds score (int), grade (str), and feedback (str). A calculate_grade node sets the grade based on score (90+=”A”, 80+=”B”, 70+=”C”, below 70=”F”). Use conditional edges to route to different feedback nodes per grade. Test with scores of 95, 85, 72, and 55.
Solution
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class QuizState(TypedDict):
score: int
grade: str
feedback: str
def calculate_grade(state: QuizState) -> dict:
s = state["score"]
if s >= 90: return {"grade": "A"}
elif s >= 80: return {"grade": "B"}
elif s >= 70: return {"grade": "C"}
else: return {"grade": "F"}
def fb_a(state: QuizState) -> dict:
return {"feedback": f"Excellent! Score: {state['score']}"}
def fb_b(state: QuizState) -> dict:
return {"feedback": f"Good work! Score: {state['score']}"}
def fb_c(state: QuizState) -> dict:
return {"feedback": f"Passing. Score: {state['score']}"}
def fb_f(state: QuizState) -> dict:
return {"feedback": f"Needs improvement. Score: {state['score']}"}
def route_grade(state: QuizState) -> str:
return {"A": "fb_a", "B": "fb_b", "C": "fb_c", "F": "fb_f"}[state["grade"]]
g = StateGraph(QuizState)
g.add_node("calculate_grade", calculate_grade)
for name, func in [("fb_a", fb_a), ("fb_b", fb_b), ("fb_c", fb_c), ("fb_f", fb_f)]:
g.add_node(name, func)
g.add_edge(name, END)
g.add_edge(START, "calculate_grade")
g.add_conditional_edges("calculate_grade", route_grade)
app = g.compile()
for score in [95, 85, 72, 55]:
r = app.invoke({"score": score, "grade": "", "feedback": ""})
print(f"Score: {score} -> {r['grade']} -> {r['feedback']}")
#> Score: 95 -> A -> Excellent! Score: 95
#> Score: 85 -> B -> Good work! Score: 85
#> Score: 72 -> C -> Passing. Score: 72
#> Score: 55 -> F -> Needs improvement. Score: 55
Up next: we’ll dig deeper into state management, reducers, message history, and handling state that grows as data piles up across many nodes.
FAQ
Can a node have more than one normal outgoing edge?
No. A node can have only one normal outgoing edge. If you need branching, use add_conditional_edges with a routing function. That’s the whole reason conditional edges exist.
# This raises an error:
# graph.add_edge("my_node", "node_a")
# graph.add_edge("my_node", "node_b") # Error! Already has outgoing edge.
# Use conditional edges for branching
graph.add_conditional_edges("my_node", routing_function)
Can I use Pydantic instead of TypedDict for state?
Yes. LangGraph works with both TypedDict and Pydantic BaseModel. Pydantic adds checks at runtime, if a node returns bad data, you get a clear error right away. For basic graphs, TypedDict does the job. For production apps where you want strict data rules, Pydantic is a solid pick.
What happens when two nodes write to the same state field?
The last node wins. Say node_a sets {"count": 5} and then node_b sets {"count": 10}. The final state has count: 10. If you want to stack values up instead of replacing them, LangGraph has reducer functions. I cover those in the next article on state management.
How many nodes can a graph hold?
There’s no hard cap. Graphs with 30+ nodes run fine in production. But once you pass 10-15 nodes, think about splitting into subgraphs. They’re easier to test and maintain. I cover subgraphs later in this learning path.
Is the run order always the same?
For straight chains (A -> B -> C), yes. For conditional edges, the path depends on state at runtime. But the routing function always gives the same answer for the same input. LangGraph adds no randomness to the order.
References
- LangGraph documentation, Graph API overview. Link
- LangGraph documentation, Concepts: Nodes. Link
- LangGraph documentation, Concepts: Edges. Link
- LangGraph documentation, Concepts: State. Link
- LangGraph documentation, Quickstart. Link
- LangGraph Cheatsheet, Core Concepts. Link
- Python documentation, TypedDict. Link
- LangGraph GitHub repository. Link
Last reviewed: March 2026 | LangGraph version: 0.3+