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

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.

langgraph

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.

workflowState moving through a two-node graphstep 1 / 4

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.

name
Chandu
message
"" (empty)
next node
greet
nodes run
0

graph.invoke({"name": "Chandu", "message": ""}) - the state after each step, exactly as stream(stream_mode="values") printed it.

workflowAn order slip filled in by three nodesstep 1 / 4

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.

items
python course, notebook, free laptop
valid_items
-
total
-
receipt
-

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.

Restaurant -> LangGraph
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.

A plain dictionary vs a TypedDict
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.

A node, explained
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 changed
The rules for a node
InputThe 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.)

Lists are replaced, not extended - checked
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 -> change -> after
Before{"name": "Chandu", "message": ""}
greet returns{"message": "Hello, Chandu!"}
After greet{"name": "Chandu", "message": "Hello, Chandu!"} - name kept, message replaced
personalize 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.

The smallest graph - START, one node, END
# 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
Output
{'message': 'Hello from LangGraph!'}
The six steps
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.

Three nodes, one shared state
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)
Output
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.

An LLM node followed by a Python node
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)
Output - a real llama3 run
{'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.

Two ways to watch the same run
# 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)
Output
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.

What happens to a bad update - checked
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.

Solution
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"])
Output
4 words: langgraph makes agents easy

Why 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

The two-node graph from the first diagram
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())
Output
{'name': 'Chandu', 'message': 'Hello, Chandu! Welcome to LangGraph.'} __start__ --> greet; greet --> personalize; personalize --> __end__;
Mistakes - which ones raise, which ones do not (all checked)
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 warning

Tip: 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

StateGraph

The builder, made from a state description.

StateGraph(State)
TypedDict

Describes the state’s keys and types - not checked at run time.

class State(TypedDict): message: str
add_node

A name and the function that does the work.

builder.add_node("greet", greet)
add_edge

Always run this node next.

builder.add_edge("greet", "personalize")
START / END

Where a run enters and where it stops.

from langgraph.graph import START, END
compile

Check the structure, build the runnable graph.

graph = builder.compile()
invoke

Run to END; returns the whole final state.

graph.invoke({"name": "Chandu"})
stream

Each node’s change, or the state after each step.

graph.stream(x, stream_mode="updates")
add_sequence

Several nodes in a line, edges included.

builder.add_sequence([clean, count, report])
ainvoke

Run 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.

Change the greeting

“Make greet say "Hi, Chandu!" and run the graph again.”

Add a field

“Add course: str to the state and make the final message "Hello, Chandu! Welcome to the Agentic AI course."”

Add a discount

“In the order graph, add an apply_discount node between calculate_total and create_receipt that takes 10% off totals above Rs 500.”

Change the question

“In the LLM example, ask "What is Kubernetes?" and add a third node that makes the answer upper case.”

Break it on purpose

“Return {"greeting": "hi"} from a node, then return "hi". Which mistake would you notice? Which one would you miss?”

Watch it

“Run the order graph with stream_mode="values" and see the empty fields fill in one by one.”

What usually goes wrong

Forgetting to compile

The builder only describes the graph; only the compiled graph can run.

✗ builder.invoke({"message": ""})
✓ graph = builder.compile()
graph.invoke({"message": ""})
No edge from START

compile() refuses with "Graph must have an entrypoint".

✗ builder.add_edge("hello", END)
✓ builder.add_edge(START, "hello")
builder.add_edge("hello", END)
Returning a key the state does not have

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"}
Changing the state in place

The change is lost - the next node sees the old value.

✗ def greet(state):
    state["message"] = "Hello"
✓ def greet(state):
    return {"message": "Hello"}
Expecting a list to grow by itself

A returned list replaces the old one. Build the full new list in the node.

✗ return {"items": ["b"]}
✓ return {"items": state["items"] + ["b"]}
One node that does everything

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

What are the three building blocks of LangGraph?
State (the shared data), nodes (functions that do work) and edges (what runs next).
In the restaurant picture, what is the state?
The order slip: one shared set of data that every person (node) reads and adds their part to.
What does a node return?
A dictionary with only the fields it wants to change - not the whole state.
greet returns only {"message": ...}. What happens to name?
It stays in the state unchanged. Only the returned fields are replaced.
What are START and END?
Two special names: START is where a run enters the graph, END is where it stops.
Why call compile()?
It checks the structure and turns the builder into a graph you can run. The builder has no invoke().
What does invoke() return?
The whole final state - every field.
How do you see what each node did?
Use graph.stream(input, stream_mode="updates"). It shows each node’s returned changes, in order.

Quiz

  1. 1.

    A node returns {"greeting": "hi"} but the state only has message. What happens?

  2. 2.

    A node sets state["message"] = "x" and returns nothing. What does the next node see?

  3. 3.

    The state has items = ["a"]. A node returns {"items": ["b"]}. What is items now?

  4. 4.

    You added a node but it never runs, and there is no error. What is the most likely cause?

  5. 5.

    When is a missing START edge reported?

  6. 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...