State, Nodes, and Edges
The three building blocks of every LangGraph program - state, nodes and edges - explained slowly, with an everyday picture, step-by-step code, an LLM node, and a practice task.
What you will be able to do
- Explain state, nodes and edges in your own words, using an everyday example
- Define the state with a TypedDict, and know what TypedDict does and does not check
- Write nodes that read the state and return only what they change
- Understand exactly how LangGraph merges a node’s update into the state
- Connect nodes with START, END and add_edge, and build a graph in six steps
- Compile and invoke a graph, and watch the state change with stream()
- Write a node that calls an LLM, next to normal Python nodes
- Recognise the mistakes that fail silently: unknown keys, wrong types, changing the state in place
The idea, in plain English
In Lesson 3.1 you saw that a graph is boxes (nodes) and arrows (edges). One question was left open: how does information move from one box to the next? In LangGraph the answer is the state.
The state is one dictionary that travels through the whole run. Think of it as a shared notebook. Every node gets the notebook, reads what it needs, does its job, and writes down only what it changed. LangGraph copies those changes into the notebook and passes it to the next node.
So every LangGraph program is built from three ideas. State answers "what do I know?" - the data. A node answers "what do I do?" - a Python function. An edge answers "where do I go next?" - an arrow from one node to another.
This lesson explains each idea slowly, with small examples you can run. Most examples need no LLM - the nodes are plain Python functions, which is the clearest way to see how the state moves. One example adds a real llama3 node. Everything was run with LangGraph 1.2.14.
Worked example: A greeting graph, an order-processing graph, and a graph where one node calls llama3.
1 - The input becomes the state
invoke() starts at START with the dictionary you passed in. The edge from START says that greet runs first.
graph.invoke({"name": "Chandu", "message": ""}) - the state after each step, exactly as stream(stream_mode="values") printed it.
1 - Only the items are known
The input has one field. valid_items, total and receipt do not exist yet - the nodes will add them.
graph.invoke({"items": ["python course", "notebook", "free laptop"]}) - each node fills one empty field. A real run.
An everyday picture: the restaurant order slip
Think of a small restaurant. When you order, the waiter writes an order slip: table number, dishes, notes. The slip goes to the kitchen. The cook reads the dishes, cooks them, and writes "ready" on the slip. Then the slip goes to the cashier, who reads the dishes and writes the bill amount. Everyone works on the same slip, and each person writes only their own part.
That is exactly how LangGraph works. The slip is the state. Each person is a node. The route the slip travels - waiter, then kitchen, then cashier - is the edges. The cook does not need to talk to the waiter; everything the cook needs is on the slip.
The order slipThe state: one shared set of data for this order (this run).Waiter, cook, cashierNodes: each does one job and writes only its own part.Waiter -> kitchen -> cashierEdges: the order in which the slip moves.Customer walks inSTART: where the run begins.Bill paid, order closedEND: where the run finishes.A new customer, a new slipA new invoke(): every run has its own state.State: what the workflow knows
The state is a Python dictionary shared by the whole run. For a question-answer graph it might start as {"question": "What is Docker?"}. After the answering node runs, it also holds an answer.
Keep two things apart in your head. The graph is the structure - which nodes exist and how they are connected. It is built once. The state is the data - it is different for every run. Graph = structure, state = data.
You describe the shape of the state with a TypedDict. If TypedDict is new to you: it is a normal Python class that lists the keys a dictionary should have, and the type of each value. It helps you, your editor and other readers know what is in the state.
from typing import TypedDict
# A plain dictionary: nothing says which keys should exist
state = {"question": "What is Docker?", "answer": ""}
# A TypedDict: a description of the keys and their types
class QAState(TypedDict):
question: str # the user's question
answer: str # the answer, filled in later by a node
# You still create and use it like a normal dictionary
state: QAState = {"question": "What is Docker?", "answer": ""}
print(state["question"])Watch out: TypedDict is a description, not a guard. Python does not check it while the program runs. In our test a node returned {"message": 42} for a field declared as str, and LangGraph stored 42 without any error.
Nodes: functions that return changes
A node is a normal Python function with one rule: it receives the current state, and it returns a dictionary with only the fields it wants to change. It does not return the whole state.
In the example below, greet reads state["name"] and returns {"message": "Hello, Chandu!"}. It does not mention name, so name stays exactly as it was. If a node has nothing to change - for example it only prints a log line - it can return {} or nothing at all. We tested both: the state passed through unchanged.
Give each node one job and a name that says what it does: classify_request, search_documents, generate_answer. Small nodes with clear names make the workflow easy to read, and you will see those names again when you stream a run or read a trace.
def greet(state: State): # 1. receives the current state
name = state["name"] # 2. reads what it needs
text = f"Hello, {name}!" # 3. does its job
return {"message": text} # 4. returns ONLY what it changedInputThe current state (a dictionary).OutputA dictionary of the fields to change - not the whole state.Nothing to changeReturn {} or return nothing. The state stays the same.Inside the nodeAnything Python can do: call an LLM, an API, a database, a calculation.Nameadd_node("greet", greet) - or add_node(greet), which uses the function name. Two nodes cannot share a name (ValueError: Node already present).How LangGraph merges a change into the state
After a node runs, LangGraph takes the dictionary it returned and copies each field into the state. Fields the node did not return are kept. A field the node returned replaces the old value completely.
That last point surprises people with lists. If the state has items = ["a"] and a node returns {"items": ["b"]}, the result is ["b"], not ["a", "b"]. To add to a list, the node must build the new list itself: state["items"] + ["b"]. (In Lesson 3.5 you will meet MessagesState, whose messages list adds automatically - that is a special rule called a reducer.)
def add_b(state):
return {"items": ["b"]} # replaces the list
def add_b_correctly(state):
return {"items": state["items"] + ["b"]} # builds the new list itself
# invoke({"items": ["a"], "note": ""})
# with add_b -> {'items': ['b'], 'note': ''}
# with add_b_correctly -> {'items': ['a', 'b'], 'note': ''}Before{"name": "Chandu", "message": ""}greet returns{"message": "Hello, Chandu!"}After greet{"name": "Chandu", "message": "Hello, Chandu!"} - name kept, message replacedpersonalize returns{"message": "Hello, Chandu! Welcome to LangGraph."}After personalize{"name": "Chandu", "message": "Hello, Chandu! Welcome to LangGraph."}Edges, START and END
add_edge("greet", "personalize") means: after greet finishes, run personalize. START and END are two special names imported from langgraph.graph. START is where the run enters, END is where it stops. Every graph needs at least one edge from START.
The order in which you call add_node does not matter - only the edges decide the order of the run. We added "two" before "one" but connected START -> one -> two, and the run printed one, then two.
A node that no edge leads to simply never runs - and LangGraph does not warn you. We added a node called orphan, connected nothing to it, and the graph compiled and ran without it. If a node seems to be "skipped", check its edges first.
Some structure mistakes are caught by compile(). A graph with no edge from START fails with ValueError: Graph must have an entrypoint: add at least one edge from START to another node. An edge to a node that was never added fails with ValueError: Found edge ending at unknown node ‘goodbye’.
Build any graph in six steps
Every LangGraph program you write in this module follows the same six steps. Learn them once and every later lesson only adds new kinds of nodes and edges.
Here is the smallest possible graph: one node. Read it top to bottom and match each part to a step in the table.
# pip install langgraph
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
# Step 1: define the state
class State(TypedDict):
message: str
# (the node)
def hello(state: State):
return {"message": "Hello from LangGraph!"}
builder = StateGraph(State) # Step 2: create the builder
builder.add_node("hello", hello) # Step 3: add the nodes
builder.add_edge(START, "hello") # Step 4: add the edges
builder.add_edge("hello", END)
graph = builder.compile() # Step 5: compile
print(graph.invoke({"message": ""})) # Step 6: invoke{'message': 'Hello from LangGraph!'}1. Define the stateWhat the workflow knows.2. Create the builderA graph that uses that state.3. Add nodesA name and the function that does the work.4. Add edgesWhat runs after what.5. CompileCheck the structure and build something runnable.6. InvokeRun it once with an input state.compile(): from a plan to a program
StateGraph(State) gives you a builder. A builder is like an architect’s drawing of a house: you can add rooms (nodes) and doors (edges), but you cannot live in it. builder.compile() checks the drawing and builds the real thing - a compiled graph that you can run.
If you forget compile() and call builder.invoke(...), Python says AttributeError: ‘StateGraph’ object has no attribute ‘invoke’. The compiled graph has invoke() to run to the end and return the final state, stream() to see each step, and get_graph().draw_mermaid() to print its structure.
Example: an order-processing graph
A more realistic example, shown in the second diagram above. The input has only items. Three nodes each add one field: validate_order keeps only items that are in the price list, calculate_total adds up the prices, and create_receipt writes the receipt.
Notice that the nodes never call each other. calculate_total does not ask validate_order for anything - it reads valid_items from the state. This is the big benefit of shared state: each node only needs to know the state, not the other nodes.
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
PRICES = {"python course": 499, "agentic ai course": 999, "notebook": 50}
class OrderState(TypedDict):
items: list # what the customer wants to buy
valid_items: list # filled by validate_order
total: int # filled by calculate_total
receipt: str # filled by create_receipt
def validate_order(state: OrderState):
known = [item for item in state["items"] if item in PRICES]
print(" validate_order: kept", known)
return {"valid_items": known}
def calculate_total(state: OrderState):
total = sum(PRICES[item] for item in state["valid_items"])
print(" calculate_total:", total)
return {"total": total}
def create_receipt(state: OrderState):
lines = [f"{item}: Rs {PRICES[item]}" for item in state["valid_items"]]
lines.append(f"TOTAL: Rs {state['total']}")
return {"receipt": "\n".join(lines)}
builder = StateGraph(OrderState)
builder.add_node("validate_order", validate_order)
builder.add_node("calculate_total", calculate_total)
builder.add_node("create_receipt", create_receipt)
builder.add_edge(START, "validate_order")
builder.add_edge("validate_order", "calculate_total")
builder.add_edge("calculate_total", "create_receipt")
builder.add_edge("create_receipt", END)
graph = builder.compile()
result = graph.invoke({"items": ["python course", "notebook", "free laptop"]})
print(result["receipt"])
print("\nfull state:", result) validate_order: kept ['python course', 'notebook']
calculate_total: 549
python course: Rs 499
notebook: Rs 50
TOTAL: Rs 549
full state: {'items': ['python course', 'notebook', 'free laptop'],
'valid_items': ['python course', 'notebook'], 'total': 549,
'receipt': 'python course: Rs 499\nnotebook: Rs 50\nTOTAL: Rs 549'}Example: a node that calls an LLM
A node can do anything a Python function can do - including calling a language model. Below, answer_question calls llama3 and stores the reply in the state. The next node, count_words, is plain Python: it reads the answer the LLM node wrote and counts its words.
This mix is normal in real agents: some nodes call a model, others check, calculate, or save data. LangGraph treats them the same way - each one reads the state and returns changes.
We used stream(stream_mode="updates") instead of invoke(). It prints what each node returned, one node at a time - the next topic explains it.
from typing import TypedDict
from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph
llm = ChatOllama(model="llama3", temperature=0)
class QAState(TypedDict):
question: str
answer: str
word_count: int
def answer_question(state: QAState):
# An LLM node: it calls the model and stores the reply in the state
reply = llm.invoke(f"Answer in one simple sentence: {state['question']}")
return {"answer": reply.content}
def count_words(state: QAState):
# A normal Python node: it reads what the LLM node wrote
return {"word_count": len(state["answer"].split())}
builder = StateGraph(QAState)
builder.add_node("answer_question", answer_question)
builder.add_node("count_words", count_words)
builder.add_edge(START, "answer_question")
builder.add_edge("answer_question", "count_words")
builder.add_edge("count_words", END)
graph = builder.compile()
for update in graph.stream({"question": "What is Docker?"}, stream_mode="updates"):
print(update){'answer_question': {'answer': 'Docker is a containerization platform that allows developers to package, ship, and run applications in isolated environments called containers, making it easier to develop, deploy, and manage software.'}}
{'count_words': {'word_count': 28}}Watching the state move: stream()
invoke() only shows you the final state. When something goes wrong, you want to see every step. That is what stream() is for. It has two useful modes.
stream_mode="updates" prints what each node returned, with the node’s name: {‘greet’: {...}}, then {‘personalize’: {...}}. stream_mode="values" prints the whole state after each step - first the input, then the state after greet, then after personalize.
This is the most useful debugging tool in LangGraph. If a node gets a value you did not expect, stream the run with "updates" and look for the node that wrote it.
# graph = the two-node greet -> personalize graph (full code at the end of this page)
for update in graph.stream({"name": "Chandu", "message": ""}, stream_mode="updates"):
print(update)
for state in graph.stream({"name": "Chandu", "message": ""}, stream_mode="values"):
print(state)stream_mode="updates" - what each node returned
{'greet': {'message': 'Hello, Chandu!'}}
{'personalize': {'message': 'Hello, Chandu! Welcome to LangGraph.'}}
stream_mode="values" - the whole state after each step
{'name': 'Chandu', 'message': ''}
{'name': 'Chandu', 'message': 'Hello, Chandu!'}
{'name': 'Chandu', 'message': 'Hello, Chandu! Welcome to LangGraph.'}Mistakes that fail silently
Some mistakes give you an error, and those are easy to fix. Returning a string instead of a dictionary fails with InvalidUpdateError: Expected dict, got hi. Reading a key that is not in the state - state["question"] when the input had no question - fails with KeyError: ‘question’. If a key may be missing, use state.get("question", "a default").
Other mistakes give no error at all, and those are the dangerous ones. A key that is not in the state is dropped without a word: a node returning {"greeting": "hi"} on a state with only message left message empty. And changing the state in place does not work: a node that wrote state["message"] = "mutated" and returned nothing lost that change - the next node still saw the old value. Always return your changes.
return "hi"Error: InvalidUpdateError: Expected dict, got hi.state["question"], key missingError: KeyError. Use state.get("question", default).return {"greeting": "hi"}No error. Ignored - greeting is not a state key.return {"message": 42}No error. Stored as 42 - types are not checked.state["message"] = "x" (no return)No error. Lost - the next node sees the old value.return {} or return NoneFine - nothing changes.Watch out: Return changes; never edit the state you were given. In our run the in-place change was simply lost - no error, no warning.
Practice: build your own graph
Try this before you read the solution. Build a graph with three nodes that cleans and measures a piece of text. The state has text (the input), clean_text, word_count and report. Node clean removes extra spaces and makes the text lower case. Node count counts the words. Node report writes "<count> words: <clean text>".
The solution uses add_sequence, a shortcut for nodes in a straight line: it adds all three nodes (named after their functions) and the edges between them. You still add the edge from START and the edge to END yourself.
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
class TextState(TypedDict):
text: str
clean_text: str
word_count: int
report: str
def clean(state: TextState):
return {"clean_text": " ".join(state["text"].split()).lower()}
def count(state: TextState):
return {"word_count": len(state["clean_text"].split())}
def report(state: TextState):
return {"report": f"{state['word_count']} words: {state['clean_text']}"}
builder = StateGraph(TextState)
builder.add_sequence([clean, count, report]) # three nodes in a line
builder.add_edge(START, "clean")
builder.add_edge("report", END)
graph = builder.compile()
print(graph.invoke({"text": " LangGraph makes AGENTS easy "})["report"])4 words: langgraph makes agents easyWhy shared state is worth it
Without a shared state, every function must pass all its data on by hand: step_a(user, question, history, tool_result, retry_count, metadata). When you add one new piece of data, you must change every function on the way. With state, a node reads what it needs and returns what it changed. Adding a field only touches the nodes that use it.
The real benefit comes with branches and loops. In Lesson 3.1’s support graph, the routing function read attempts and status from the state to choose between check, respond and escalate. In an agent, the state will hold the list of messages, and the model and tools nodes will take turns adding to it.
Common questions
Can a node call an LLM, an API, or a database? Yes. A node is just a Python function, so it can do anything Python can do. The LLM example above shows it.
Can a node be async? Yes - write async def and run the graph with await graph.ainvoke(...). We tested it: ainvoke worked, but invoke() on a graph with an async node failed with TypeError: No synchronous function provided.
Do I have to pass every state field in the input? No. The order example passed only items, and the nodes added the rest. But a node that reads a missing key with state["key"] fails with KeyError.
What does invoke() return? The whole final state - all fields - not only what the last node returned. Extra input keys that are not in the TypedDict are dropped from it.
Step-by-step code
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
name: str
message: str
def greet(state: State):
return {"message": f"Hello, {state['name']}!"}
def personalize(state: State):
return {"message": state["message"] + " Welcome to LangGraph."}
builder = StateGraph(State)
builder.add_node("greet", greet)
builder.add_node("personalize", personalize)
builder.add_edge(START, "greet")
builder.add_edge("greet", "personalize")
builder.add_edge("personalize", END)
graph = builder.compile()
print(graph.invoke({"name": "Chandu", "message": ""}))
print(graph.get_graph().draw_mermaid()){'name': 'Chandu', 'message': 'Hello, Chandu! Welcome to LangGraph.'}
__start__ --> greet;
greet --> personalize;
personalize --> __end__;builder.invoke({"message": ""}) # forgot compile()
AttributeError: 'StateGraph' object has no attribute 'invoke'
no edge from START # at compile()
ValueError: Graph must have an entrypoint: add at least one edge from START to another node
add_edge("hello", "goodbye") # at compile()
ValueError: Found edge ending at unknown node 'goodbye'
add_node("n", f) twice # when adding
ValueError: Node ... already present.
node returns "hi" # at invoke()
InvalidUpdateError: Expected dict, got hi
node reads state["question"], not given # at invoke()
KeyError: 'question'
node returns {"greeting": "hi"} # silently ignored
{'message': ''}
node returns {"message": 42} # types not enforced
{'message': 42}
node sets state["message"] = "mutated" and returns nothing
next node saw 'original' # the change was lost
a node with no edge leading to it # compiles, never runs
no error, no warningTip: When a node sees a value you did not expect, run the graph with stream_mode="updates". It shows exactly which node wrote what, in order.
LangGraph basics at a glance
StateGraphThe builder, made from a state description.
StateGraph(State)
TypedDictDescribes the state’s keys and types - not checked at run time.
class State(TypedDict): message: str
add_nodeA name and the function that does the work.
builder.add_node("greet", greet)add_edgeAlways run this node next.
builder.add_edge("greet", "personalize")START / ENDWhere a run enters and where it stops.
from langgraph.graph import START, END
compileCheck the structure, build the runnable graph.
graph = builder.compile()
invokeRun to END; returns the whole final state.
graph.invoke({"name": "Chandu"})streamEach node’s change, or the state after each step.
graph.stream(x, stream_mode="updates")
add_sequenceSeveral nodes in a line, edges included.
builder.add_sequence([clean, count, report])
ainvokeRun a graph that has async nodes.
await graph.ainvoke({...})Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Make greet say "Hi, Chandu!" and run the graph again.”
“Add course: str to the state and make the final message "Hello, Chandu! Welcome to the Agentic AI course."”
“In the order graph, add an apply_discount node between calculate_total and create_receipt that takes 10% off totals above Rs 500.”
“In the LLM example, ask "What is Kubernetes?" and add a third node that makes the answer upper case.”
“Return {"greeting": "hi"} from a node, then return "hi". Which mistake would you notice? Which one would you miss?”
“Run the order graph with stream_mode="values" and see the empty fields fill in one by one.”
What usually goes wrong
The builder only describes the graph; only the compiled graph can run.
✗ builder.invoke({"message": ""})✓ graph = builder.compile()
graph.invoke({"message": ""})compile() refuses with "Graph must have an entrypoint".
✗ builder.add_edge("hello", END)✓ builder.add_edge(START, "hello")
builder.add_edge("hello", END)It is dropped without an error. When a change seems to disappear, compare the key with the TypedDict.
✗ return {"greeting": "Hello"} # State has message, not greeting✓ return {"message": "Hello"}The change is lost - the next node sees the old value.
✗ def greet(state):
state["message"] = "Hello"✓ def greet(state):
return {"message": "Hello"}A returned list replaces the old one. Build the full new list in the node.
✗ return {"items": ["b"]}✓ return {"items": state["items"] + ["b"]}A do_everything node hides the workflow inside one function. Split it, so each step is a node with its own name.
✗ builder.add_node("process", process_everything)✓ builder.add_sequence([classify, search, validate, generate])Key points
- State = the shared data of one run. Graph = the structure, built once.
- Describe the state with a TypedDict - it helps readers, but is not checked when the program runs.
- A node is a function: it reads the state and returns only the fields it changes.
- LangGraph copies each returned field into the state; fields not returned stay; a returned field replaces the old value (lists too).
- Edges decide the order; every graph needs an edge from START, and a node with no edge leading to it never runs.
- Six steps: state, StateGraph, add_node, add_edge, compile, invoke.
- stream(stream_mode="updates" or "values") shows the state changing step by step.
- Unknown keys are ignored and in-place changes are lost - with no error.
Quick check before you move on
Quiz
- 1.
A node returns {"greeting": "hi"} but the state only has message. What happens?
- 2.
A node sets state["message"] = "x" and returns nothing. What does the next node see?
- 3.
The state has items = ["a"]. A node returns {"items": ["b"]}. What is items now?
- 4.
You added a node but it never runs, and there is no error. What is the most likely cause?
- 5.
When is a missing START edge reported?
- 6.
What is the difference between stream_mode="updates" and "values"?
Interview questions
What is StateGraph?
The LangGraph builder for a workflow with shared state. You give it a state schema, add nodes and edges, then compile it into a runnable graph that you invoke or stream.
What is the difference between a node and an edge?
A node does work - a function that reads the state and returns changes. An edge controls the flow - which node runs after which.
How does data flow between nodes?
Each node receives the current state and returns a partial update. LangGraph merges it - returned fields replace old values, others are kept - and passes the merged state along the edges to the next node.
Why does LangGraph use shared state?
Nodes read what they need and write what they change, instead of passing every value through every function. This matters most once a graph branches and loops.
What happens at compile time?
The structure is checked - an entry from START, no edges to unknown nodes - and a runnable CompiledStateGraph is returned.
Which state mistakes do not raise errors, and how do you find them?
Returning unknown keys (ignored), wrong types (stored as given), and changing the state in place (lost). Stream the run with stream_mode="updates" to see what each node actually returned.
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