If you read my LangChain for Beginners post, you built your first create_agent and I told you it runs on something called LangGraph "under the hood." This post opens that hood. LangGraph is the runtime that lets an agent loop, remember, branch, and pause for a human — and once you can see the graph, a lot of agent behavior that felt like magic becomes obvious.
You don't strictly need LangGraph for simple apps. But the moment you want an agent that loops over tools, remembers across turns, pauses for approval, or routes a request down different paths, you're already using LangGraph — create_agent just hid it from you. Learning it directly is how you go from "I called a prebuilt" to "I can build the exact control flow my problem needs."
A note before we start: LangGraph moves fast. This guide targets the 1.x line — 1.0 went generally available in October 2025, and 1.2 is current as of mid-2026. If a tutorial tells you to use create_react_agent from langgraph.prebuilt, MessageGraph, or hand-built while loops around AgentExecutor, it predates 1.0. In particular, create_react_agent is now deprecated in favor of create_agent (which moved to the langchain package). We'll use the modern patterns throughout and flag the old ones so you don't get confused.
Part 1: The basics (what problem are we even solving?)
In the LangChain guide we built chains with LCEL: prompt | model | parser. A chain is a straight line — technically a DAG, a directed graph with no cycles. Data flows one way, start to finish. That's perfect when your steps are fixed and known in advance.
But real agentic apps aren't straight lines. They need to:
- Loop. An agent calls a tool, looks at the result, and decides whether to call another — possibly the same one again. That's a cycle, and an LCEL chain fundamentally cannot express a cycle.
- Branch on state. Route a refund request down a different path than a status check.
- Carry state. Accumulate messages, scratchpad notes, and retrieved documents across many steps.
- Pause and resume. Stop for human approval, then continue exactly where it left off — even after the process restarts.
You can hand-roll all of this with a while loop, a dictionary of state, and a pile of if statements. People do. It works until it doesn't: the loop gets tangled, state mutation gets buggy, and "resume after a crash" turns into a weekend you won't get back.
LangGraph is a runtime for stateful, multi-step LLM workflows modeled as a graph. You describe your application as nodes (the steps) and edges (the transitions between them). LangGraph runs the graph, manages the shared state, and hands you persistence, streaming, and human-in-the-loop for free.
Part 2: What LangGraph actually is
LangGraph is a low-level orchestration framework from the LangChain team for building stateful, agentic applications. "Low-level" is the operative word: it doesn't assume your app is a chatbot, an agent, or a RAG pipeline. It gives you the primitives — state, nodes, edges — and you assemble the control flow.
How it relates to LangChain (worth being precise about, because it trips up nearly everyone):
- LangChain is the high-level layer: models, prompts, tools, and create_agent. Fast to assemble, opinionated.
- LangGraph is the execution runtime underneath. create_agent is literally a graph that LangGraph runs for you.
- You reach for LangGraph directly when the prebuilt agent loop isn't the shape you need — custom routing, multiple cooperating agents, explicit human checkpoints, or deterministic multi-step pipelines with persistence and retries.
The mental model is three pieces — that's the whole framework:
- State — a shared object every node can read and write. It's the graph's memory for a single run.
- Nodes — plain Python functions. Each takes the current state and returns an update to it.
- Edges — the wiring that decides which node runs next. Edges are either fixed ("after A, do B") or conditional ("after A, look at the state and decide where to go").
Everything else — agents, RAG loops, multi-agent systems — is just those three pieces arranged differently.
The package ecosystem (a quick map so imports don't surprise you):
- langgraph — the core: StateGraph, the prebuilt ToolNode / tools_condition, and the runtime itself.
- langgraph-checkpoint — the base persistence interfaces (pulled in automatically).
- langgraph-checkpoint-sqlite, langgraph-checkpoint-postgres — durable checkpointers; install the one you need.
- langgraph-supervisor, langgraph-swarm — prebuilt multi-agent architectures (intermediate territory).
- langchain — where create_agent now lives. It's built on LangGraph.
Installing. You need Python 3.10+. We'll use a local Ollama model so every example runs with no API key:
pip install -U langgraph langchain langchain-ollama
Swap in any provider you like — the graph code doesn't change.
Part 3: The building blocks, one at a time
We'll build up from a single node to a full agent loop. Each step adds exactly one new concept.
Building block 1: State
The state is the single object that flows through your graph. Every node reads it and returns updates to it. You define its shape with a TypedDict.
The one genuinely new idea here is reducers. By default, when a node returns a value for a key, it replaces whatever was there. But for something like a message history, you want to append, not overwrite. You declare that with Annotated and a reducer function. The built-in add_messages reducer appends new messages (and de-duplicates by ID):
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages] # append, don't overwrite
Because "a list of messages" is so common, LangGraph ships a prebuilt state called MessagesState that has exactly this field. Subclass it when you need more:
from langgraph.graph import MessagesState
class State(MessagesState): # inherits the `messages` field + add_messages
documents: list[str] # ...and add your own
Building block 2: Nodes
A node is just a function: state in, partial update out. You return only the keys you changed, and the reducers merge it into the running state.
from langchain.chat_models import init_chat_model
model = init_chat_model("ollama:gemma4", temperature=0)
def chatbot(state: State):
response = model.invoke(state["messages"])
return {"messages": [response]} # add_messages appends this for you
Notice what you didn't do: no mutating state in place, no managing the message list by hand. You return the delta; LangGraph applies it. That discipline is what makes the whole thing predictable.
Building block 3: Edges, and your first graph
You assemble nodes with a StateGraph. START and END are special sentinels marking where execution begins and finishes.
from langgraph.graph import StateGraph, START, END
builder = StateGraph(State)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot") # entry point
builder.add_edge("chatbot", END) # finish
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "Hello!"}]})
print(result["messages"][-1].content)
compile() turns the builder into a runnable graph. Crucially, the compiled graph implements the same Runnable interface you already know from LCEL — .invoke(), .stream(), .batch(), and the async variants. Everything you learned about chains still applies.
Building block 4: Conditional edges (branching)
A fixed edge always goes to the same place. A conditional edge runs a small function that inspects the state and returns the name of the next node. This is how a graph makes decisions:
def route(state: State) -> str:
last = state["messages"][-1].content.lower()
return "escalate" if "refund" in last else "answer"
builder.add_conditional_edges(
"triage",
route,
{"escalate": "escalate", "answer": "answer"}, # map return value -> node
)
The third argument maps the routing function's return value to a node name. (You can omit it if your function already returns node names directly.) This single primitive is the difference between a script and a system that adapts to its input.
Building block 5: Cycles — the agent loop
Here's the thing chains can't do and graphs can: loop back. The classic agent is two nodes in a cycle — a model node and a tool node — with a conditional edge deciding whether to keep going.
LangGraph ships two prebuilts that make this almost free:
- ToolNode([...]) — a ready-made node that reads the tool calls in the last message, runs those tools, and appends the results back as ToolMessages.
- tools_condition — a ready-made routing function for add_conditional_edges. It looks at the last message: if the model asked for a tool, it returns "tools"; if not, it returns END. (That's why you don't add an explicit edge to END here — tools_condition handles it.)
Here's the full, runnable agent — model, tools, graph, and a call so you can see the loop actually turn:
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
# 1. Define the tool(s) the model is allowed to call
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
return f"It's 22°C and sunny in {city}."
tools = [get_weather]
# 2. Give the model the tool schemas so it knows it *can* call them
model = init_chat_model("ollama:gemma4", temperature=0)
model_with_tools = model.bind_tools(tools)
# 3. The model node: call the model, append its reply to the state
def chatbot(state: MessagesState):
return {"messages": [model_with_tools.invoke(state["messages"])]}
# 4. Wire the two nodes into a cycle
builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "chatbot")
builder.add_conditional_edges("chatbot", tools_condition) # asked for a tool? -> "tools", else -> END
builder.add_edge("tools", "chatbot") # after running tools, loop back
graph = builder.compile()
# 5. Run it
result = graph.invoke({"messages": [{"role": "user", "content": "What's the weather in Tokyo?"}]})
print(result["messages"][-1].content) # -> "It's 22°C and sunny in Tokyo."
Read the wiring out loud: start at the chatbot; if it asked for a tool, run the tools node and go back to the chatbot with the result; if it didn't, stop. Walking through this one call, state["messages"] grows through exactly one trip around the loop:
1. HumanMessage "What's the weather in Tokyo?" # your input
2. AIMessage tool_calls=[get_weather(city="Tokyo")] # chatbot decides to call the tool
3. ToolMessage "It's 22°C and sunny in Tokyo." # ToolNode runs it, appends the result
4. AIMessage "It's 22°C and sunny in Tokyo." # chatbot sees the result, gives the final answer
After step 2, tools_condition sees a tool call and routes to tools. After step 3, the edge loops back to chatbot. After step 4 there's no tool call, so tools_condition routes to END and the run finishes. If a question needed three tool calls, the graph would simply circle the loop three times — that's the part a straight chain can't do.
That cycle — think, act, observe, repeat — is an agent. You just built one from primitives. (create_agent builds essentially this exact graph for you.)
Building block 6: Persistence (memory across turns)
By itself, a graph forgets everything between .invoke() calls. Attach a checkpointer and it saves the full state after every step, keyed by a thread_id. Same thread → same conversation.
from langgraph.checkpoint.memory import InMemorySaver
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "user-123"}}
graph.invoke({"messages": [{"role": "user", "content": "My name is Sam."}]}, config)
reply = graph.invoke({"messages": [{"role": "user", "content": "What's my name?"}]}, config)
print(reply["messages"][-1].content) # knows it's "Sam"
The thread_id is what keeps different users' conversations separate. InMemorySaver is for learning; you swap it for a SQLite- or Postgres-backed checkpointer (covered in the intermediate post) when state must survive a restart. That same checkpointer is also what makes pause/resume and "time travel" possible — but first things first.
Building block 7: Streaming
A graph can stream its progress, not just its final answer. stream_mode picks what you see:
- "updates" — what each node changed, the moment it finishes (great for showing progress).
- "values" — the full state after each step.
- "messages" — LLM tokens as they're generated (the ChatGPT "typing" effect).
for chunk in graph.stream(
{"messages": [{"role": "user", "content": "What's the weather in Tokyo?"}]},
config,
stream_mode="updates",
):
print(chunk)
You wrote zero streaming code — the graph gives it to you because the compiled graph is a Runnable.
Building block 8: create_agent — the prebuilt that wraps all of this
Now the payoff. Everything above — model node, tool node, the loop, the checkpointer — is exactly what create_agent assembles for you. If the standard agent loop is what you need, use it; don't hand-build:
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
model="ollama:gemma4",
tools=[get_weather],
system_prompt="You are a helpful assistant.",
checkpointer=InMemorySaver(),
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "Weather in Tokyo?"}]},
{"configurable": {"thread_id": "1"}},
)
print(result["messages"][-1].content)
The reason to learn the graph underneath is not to stop using create_agent. It's so that when you need something the prebuilt doesn't give you — custom branching, multiple agents, a human approval step — you know how to drop down a level and build precisely that.
Part 4: The benefits of using LangGraph
Now that you've seen the pieces, here's where LangGraph genuinely earns its place:
Cycles and control flow as first-class citizens. Loops and branches are the whole point. Expressing "keep calling tools until done" or "route based on a classification" is natural here and awkward-to-impossible in a straight chain.
Durable state for free. A checkpointer persists every step. Conversation memory, crash recovery, and resuming a long workflow are configuration, not custom database code.
Human-in-the-loop built in. Because state is checkpointed, the graph can pause mid-run, wait for a human decision, and resume — even days later, even after a restart. (Intermediate post.)
Streaming at every granularity. Stream node updates, full state, or raw tokens with a single argument.
It's the foundation, not a silo. create_agent, langgraph-supervisor, and langgraph-swarm are all built on LangGraph. Learning it makes all of them legible.
Production pedigree. LangGraph 1.0 shipped after more than a year of real-world use at companies like Uber, LinkedIn, and Klarna. The durability and persistence features exist because production systems demanded them.
Part 5: The honest cons (and when not to use it)
A good guide tells you the downsides so you can choose well.
It's lower-level — more concepts, more code. For a single model call or a fixed two-step flow, LangGraph is overkill. Reach for LCEL or the provider SDK instead.
The graph model has a learning curve. State, reducers, and conditional edges take a beat to click. The payoff is real, but it's not instant.
It's easy to over-engineer. Not every app needs a graph, and not every agent needs to be hand-built. If create_agent fits your problem, use it — dropping to the graph is for when it genuinely doesn't.
Debugging a cyclic, stateful system is harder than a straight line. A loop that runs one step too many or a reducer that overwrites instead of appends can be subtle. Set up tracing (LangSmith) early; being able to see each node's input and output is worth a lot.
Breaking-change cadence. Like the rest of the ecosystem, LangGraph evolves quickly. Pin your versions (langgraph>=1,<2) so an upgrade doesn't surprise you mid-sprint.
Part 6: With vs. without LangGraph, side by side
The same task — an agent that calls a tool, looks at the result, and loops until it has an answer — shows the trade-off.
Without a framework (hand-rolled loop): you own the loop, the message list, the tool dispatch, and the stop condition.
messages = [{"role": "user", "content": "Weather in Tokyo?"}]
while True:
ai = model_with_tools.invoke(messages)
messages.append(ai)
if not ai.tool_calls: # no tool requested -> we're done
break
for call in ai.tool_calls: # run each requested tool by hand
result = get_weather.invoke(call["args"])
messages.append({"role": "tool", "content": result, "tool_call_id": call["id"]})
print(messages[-1].content)
This works. But you're now responsible for the loop, the stop condition, the tool-call plumbing — and there's still no persistence, streaming, or human checkpoint.
With LangGraph: the same loop, stop condition, and tool plumbing are the graph itself — you declare the two nodes and the edges, and the runtime drives them.
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
def chatbot(state: MessagesState):
return {"messages": [model_with_tools.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_node("tools", ToolNode([get_weather]))
builder.add_edge(START, "chatbot")
builder.add_conditional_edges("chatbot", tools_condition) # loop, or stop — handled for you
builder.add_edge("tools", "chatbot")
agent = builder.compile() # add checkpointer=... for memory
print(agent.invoke({"messages": [{"role": "user", "content": "Weather in Tokyo?"}]})["messages"][-1].content)
No while, no manual break, no hand-built tool dispatch — and the additions you'd reach for next are one argument each rather than a rewrite: stream_mode="updates" on .stream() for streaming, checkpointer=InMemorySaver() at compile time for memory, and a human-approval pause (intermediate post) for a checkpoint.
For a one-tool toy, the hand-rolled loop is arguably simpler. LangGraph pays off the moment you add persistence, streaming, human approval, or a second path — because each of those is a built-in, not custom code you maintain.
Part 7: Common beginner pitfalls
Forgetting the reducer. Return {"messages": [msg]} on a plain list field (no add_messages) and you overwrite the history instead of appending. Lost-context bugs almost always trace back to a missing reducer.
Returning the whole state instead of a delta. A node should return only the keys it changed. Returning the entire state (especially the full message list) fights the reducers and duplicates data.
No checkpointer, then surprised there's no memory. Memory requires a checkpointer and a stable thread_id. Leave either out and every call starts from scratch.
Reusing one thread_id across users. That leaks one person's conversation into another's session — a real privacy bug, not a hypothetical.
Following pre-1.0 tutorials. If you see create_react_agent from langgraph.prebuilt, MessageGraph, or AgentExecutor, the material predates 1.0. Prefer StateGraph and create_agent.
Reaching for a graph when create_agent would do. Drop to the raw graph only when you need control the prebuilt doesn't give. Otherwise you're maintaining plumbing you didn't need to write.
Conclusion
LangGraph is best understood as the runtime under your agents: state, nodes, and edges, with persistence, streaming, and human-in-the-loop layered on top. Chains (LCEL) are straight lines; graphs add the cycles, branches, and durable state that real agents need.
You don't always need it — for fixed pipelines and standard agents, LCEL and create_agent are simpler and you should reach for them first. But when you need to control the exact flow — a custom loop, a routing decision, a human checkpoint, several cooperating agents — LangGraph is the tool. And the nice part is that create_agent is just one graph built on top of it, so nothing you've learned here is throwaway.
Now that you know the building blocks and the trade-offs, you can decide for yourself when to use the prebuilt and when to open the hood — which is exactly where a beginner should end up.
Because LangGraph evolves quickly, always cross-check specifics — package names, model strings, and recommended APIs — against the official documentation at docs.langchain.com before shipping.
Infographic Summary
Langgraphic for beginners - infographic by Vinay C
Full Code — which actually works.
The full, runnable code for all the building blocks — against a local Ollama model, no API keys required — is in the accompanying files below. Pull the model once with ollama pull gemma4 (use whatever model you actually have), then run python main_langgraph.py.
requirements.txt
langgraph
langchain
langchain-ollama
main_langgraph.py
"""LangGraph examples for BEGINNERS with a local Ollama (gemma4) model.
Companion code for the "Honest Guide - LangGraph for Beginners" article.
Builds up from a single node to a full agent loop, then shows that
create_agent assembles the same graph for you. No API keys required.
"""
from typing import Annotated, TypedDict
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, END, MessagesState
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver
model = init_chat_model("ollama:gemma4", temperature=0)
# Example 1: State + a single node + edges -----------------------------------
print("=== Example 1: A minimal graph (state -> node -> END) ===")
class State(TypedDict):
messages: Annotated[list, add_messages] # append, don't overwrite
def chatbot(state: State):
return {"messages": [model.invoke(state["messages"])]}
builder = StateGraph(State)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)
graph = builder.compile()
result = graph.invoke({"messages": [{"role": "user", "content": "Say hello in one short sentence."}]})
print(result["messages"][-1].content)
# Example 2: Conditional edges (branching) -----------------------------------
print("\n=== Example 2: Conditional routing (a triage graph) ===")
class TriageState(TypedDict):
request: str
answer: str
def triage(state: TriageState):
return {} # no state change; routing happens on the conditional edge
def route(state: TriageState) -> str:
return "refund" if "refund" in state["request"].lower() else "general"
def refund_node(state: TriageState):
return {"answer": "Routed to the REFUNDS desk."}
def general_node(state: TriageState):
return {"answer": "Routed to GENERAL support."}
tb = StateGraph(TriageState)
tb.add_node("triage", triage)
tb.add_node("refund", refund_node)
tb.add_node("general", general_node)
tb.add_edge(START, "triage")
tb.add_conditional_edges("triage", route, {"refund": "refund", "general": "general"})
tb.add_edge("refund", END)
tb.add_edge("general", END)
triage_graph = tb.compile()
for req in ["I want a refund for my order", "How do I reset my password?"]:
print(f" {req!r} -> {triage_graph.invoke({'request': req, 'answer': ''})['answer']}")
# Example 3: The agent loop (cycle of model <-> tools) -----------------------
print("\n=== Example 3: The agent loop (ToolNode + tools_condition) ===")
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a given city."""
return f"It's 22°C and sunny in {city}."
@tool
def multiply(a: float, b: float) -> float:
"""Multiply two numbers together."""
return a * b
tools = [get_weather, multiply]
model_with_tools = model.bind_tools(tools)
def agent_node(state: MessagesState):
return {"messages": [model_with_tools.invoke(state["messages"])]}
ab = StateGraph(MessagesState)
ab.add_node("agent", agent_node)
ab.add_node("tools", ToolNode(tools))
ab.add_edge(START, "agent")
ab.add_conditional_edges("agent", tools_condition) # tools, or END?
ab.add_edge("tools", "agent") # loop back with the result
agent_graph = ab.compile()
out = agent_graph.invoke(
{"messages": [{"role": "user", "content": "What's the weather in Tokyo, and what's 23 times 7?"}]}
)
print(out["messages"][-1].content)
# Example 4: Streaming the graph's progress ----------------------------------
print("\n=== Example 4: Streaming (stream_mode='updates') ===")
for chunk in agent_graph.stream(
{"messages": [{"role": "user", "content": "What's the weather in Paris?"}]},
stream_mode="updates",
):
for node_name, update in chunk.items():
print(f" [{node_name}] produced {len(update.get('messages', []))} message(s)")
# Example 5: Persistence / memory across turns -------------------------------
print("\n=== Example 5: Memory with a checkpointer + thread_id ===")
memory_graph = ab.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "user-123"}}
memory_graph.invoke({"messages": [{"role": "user", "content": "My name is Sam."}]}, config)
reply = memory_graph.invoke({"messages": [{"role": "user", "content": "What's my name?"}]}, config)
print(reply["messages"][-1].content) # should remember "Sam"
# Example 6: create_agent assembles the same graph for you -------------------
print("\n=== Example 6: create_agent (the prebuilt wraps Examples 3 + 5) ===")
from langchain.agents import create_agent
agent = create_agent(
model="ollama:gemma4",
tools=tools,
system_prompt="You are a helpful assistant. Use tools when they help.",
checkpointer=InMemorySaver(),
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "Weather in Berlin, and what's 12 * 9?"}]},
{"configurable": {"thread_id": "1"}},
)
print(result["messages"][-1].content)
Originally published on LinkedIn.