This is the first post in our LangGraph fundamentals series. Later posts cover building a stateful agent, conditional edges, human-in-the-loop, and multi-agent systems.
The LangChain agent from our previous series already loops — calling tools, feeding results back, deciding a next step, repeating. That loop works well right up until the workflow it's modeling needs something a simple loop can't naturally express: revisiting an earlier step based on a later one's result, running two branches in parallel and merging them, or pausing indefinitely for a human decision before continuing. LangGraph exists specifically for that harder shape of problem.
Why a chain's linear shape becomes limiting
A chain (from our LangChain series) is fundamentally A → B → C — each step runs once, in a fixed order, and there's no natural way to say "if C's result looks wrong, go back to A" or "run B and D at the same time, then combine their results before C." An AgentExecutor's loop handles some of this by repeatedly deciding "call another tool, or finish" — but it's still one loop, not an explicit, inspectable structure describing genuinely different possible paths through a workflow.
The graph model: nodes, edges, and shared state
LangGraph represents a workflow as an explicit graph: nodes are steps (each an ordinary Python function), edges define which node runs next, and a shared state object flows through every node, with each node able to read and update it.
from typing import TypedDict
from langgraph.graph import StateGraph, END
class AgentState(TypedDict):
question: str
context: str
answer: str
def retrieve(state: AgentState) -> AgentState:
state["context"] = "Retrieved: full refunds within 30 days of purchase."
return state
def generate(state: AgentState) -> AgentState:
state["answer"] = f"Based on our policy: {state['context']}"
return state
graph = StateGraph(AgentState)
graph.add_node("retrieve", retrieve)
graph.add_node("generate", generate)
graph.set_entry_point("retrieve")
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", END)
app = graph.compile()
result = app.invoke({"question": "Can I get a refund?", "context": "", "answer": ""})
print(result["answer"])Based on our policy: Retrieved: full refunds within 30 days of purchase.
This specific example — retrieve then generate, in a straight line — is functionally identical to the RAG chain built with plain LangChain in our previous series. Written as a graph, it's more verbose for this exact case, and that's an honest trade-off worth naming directly: a straight-line workflow doesn't need a graph. The graph model earns its extra structure the moment a workflow actually needs branching, looping, or pausing — which is exactly what the remaining posts in this series build toward.
The state object: what actually makes this different from a chain
In LCEL (LangChain's chain-composition syntax from our previous series), each step's output becomes the next step's input directly. In LangGraph, every node reads from and writes to the same shared state object, which persists across the entire graph's execution — a node three steps later can still see something a much earlier node wrote, without it having been explicitly threaded through every intermediate step's input and output along the way. This is the structural feature that makes revisiting earlier context, and the branching and looping covered in the next posts in this series, genuinely natural to express, rather than awkward workarounds bolted onto a fundamentally linear pipeline.
Reducers: controlling how state updates actually merge
By default, a node's return value replaces the corresponding state field entirely. For fields that should accumulate rather than overwrite — a running list of messages, a log of every tool call made — LangGraph supports reducers, which control exactly how a node's update combines with existing state:
from typing import Annotated
import operator
class ConversationState(TypedDict):
question: str
messages: Annotated[list, operator.add]Annotated[list, operator.add] tells LangGraph that a node returning a new list for messages should have that list appended to the existing one (via operator.add, i.e. list concatenation), not replace it outright. This distinction matters immediately once a graph has more than one node writing to the same field — without a reducer, the second node to run would silently overwrite whatever the first one contributed, rather than the two contributions accumulating together the way a running conversation history actually needs to.
Compiling and running
app = graph.compile()compile() validates the graph's structure (every node reachable, no dangling edges to nodes that don't exist) and produces a runnable object — conceptually similar to compiling a Terraform configuration before apply, or building a Dockerfile into an image before running it: a validation and preparation step, distinct from actually executing the thing it describes.
Visualizing a graph before running it
For anything beyond a trivial example, it's worth actually looking at a graph's structure rather than trying to hold it in your head from the node/edge definitions alone:
from IPython.display import Image
Image(app.get_graph().draw_mermaid_png())This renders the compiled graph's actual node and edge structure as a diagram — genuinely useful the first time a graph grows past three or four nodes, the same "just look at the actual shape of it" instinct behind terraform graph, covered in our Terraform series, applied here to an agentic workflow's structure instead of an infrastructure dependency graph.
When to reach for LangGraph over a plain LangChain chain
- A straight-line pipeline (retrieve, then generate; format, then call, then parse) — a plain LCEL chain is simpler, more transparent, and entirely sufficient. Don't reach for a graph by default.
- A workflow that genuinely branches, loops back, or needs to pause and resume (an approval step, a retry loop with a different strategy on failure, multiple independent paths that later merge) — this is where LangGraph's explicit graph structure earns its complexity, covered directly in the remaining posts in this series.
Typed state with Pydantic, not just TypedDict
TypedDict (used throughout this post) is a lightweight way to define state's shape, but it provides no runtime validation — a node accidentally returning a string where an integer was expected fails silently or produces a confusing downstream error. Using a Pydantic model instead adds genuine runtime validation:
from pydantic import BaseModel
from langgraph.graph import StateGraph
class AgentState(BaseModel):
question: str
context: str = ""
answer: str = ""
attempts: int = 0
graph = StateGraph(AgentState)A node returning a value that doesn't match AgentState's declared types now fails immediately, with a clear validation error, rather than silently propagating a wrong value several steps further into the graph before it causes a confusing failure somewhere else entirely — the same "fail fast and clearly, not several steps later" principle covered for typed Terraform variables earlier in this blog, applied here to a graph's own state.
Multiple entry points based on how a graph is invoked
A graph doesn't have to have exactly one fixed entry point — set_conditional_entry_point lets the very first node itself be chosen dynamically, useful when a graph handles genuinely different kinds of initial requests differently:
def route_initial_request(state: AgentState) -> str:
return "retrieve" if state.question else "clarify"
graph.set_conditional_entry_point(route_initial_request, {"retrieve": "retrieve", "clarify": "clarify"})This extends the same conditional-routing mechanism covered throughout the rest of this series to the very start of a graph's execution, not just to transitions between nodes partway through — useful for a graph that needs to behave differently depending on what kind of input it actually received, from the first step onward.
What to actually remember from this post
- A chain runs a fixed, linear sequence; a graph can branch, loop, and pause — a structurally different capability, not just a more complex-sounding wrapper around the same thing.
- Nodes are functions; edges define what runs next; state is shared and persists across the whole execution — the three concepts everything else in LangGraph builds from.
- Reducers (like
Annotated[list, operator.add]) control whether a node's update replaces or accumulates onto existing state — necessary the moment more than one node writes to the same field. - A straight-line workflow doesn't need LangGraph — reach for it specifically when a workflow's actual shape isn't linear, and visualize a graph's structure directly once it grows past a handful of nodes.
Next in the series: Building Your First Stateful Agent with LangGraph, where we build something a plain chain genuinely can't express cleanly: a loop that revisits an earlier step based on a later one's result.
