ai/courses

AI agents/Tutorial

LangGraph Tutorial: Build a Stateful AI Agent in Python, Step by Step

One support agent, built up in six steps with LangGraph 1.x: a minimal graph, a hand-written ReAct loop with tools, memory with a checkpointer, human approval with interrupt(), and streaming. Then the prebuilt shortcut, Studio, and when LangGraph is the wrong tool.

Updated
Reading time
11 min
Checked against
11 sources
A graph with a start point, nodes, a conditional branch, a loop and a pause

TL;DR

The Short Answer

To build a LangGraph agent in Python:

  1. uv add langgraph langchain "langchain[openai]" (LangGraph is at 1.2.x; Python 3.10+) and set OPENAI_API_KEY.
  2. Define state (MessagesState for a chat agent), write nodes as plain functions that return partial state updates, and connect them with edges from START to END.
  3. Add tools: model.bind_tools(tools), a ToolNode(tools) node, and a conditional edge with tools_condition that loops back to the model. That's a ReAct agent written by hand.
  4. Compile with a checkpointer (InMemorySaver) and pass a thread_id to get memory.
  5. Call interrupt() inside a node or tool to pause for human approval, and continue with Command(resume=...).
  6. Use graph.stream(..., stream_mode="updates") or "messages" to stream progress and tokens.

Heads-up: if you only need a standard tool-calling agent, LangChain's create_agent builds this whole loop in one call, and it runs on LangGraph. langgraph.prebuilt.create_react_agent, which most older tutorials use, is deprecated in LangGraph v1.

01

Introduction to LangGraph - Python

Visit ↗

LangChain Academy · Free · certificate not stated · Intermediate · 6 hours of video

The best free LangGraph course: six hours from the team that builds it, covering reducers, interrupts, time travel, subgraphs, long-term memory and deployment, well past what this tutorial covers.

02

AI Agents in LangGraph

Visit ↗

DeepLearning.AI (with LangChain and Tavily) · Free · paid certificate · Intermediate · 1h 47m

A fast, under-two-hour intro where Harrison Chase builds an agent from scratch and then in LangGraph; it was recorded before LangGraph 1.0, so check its imports against the current docs.

LangGraph Core Concepts: State, Nodes, Edges, Reducers

LangGraph describes itself as "a low-level orchestration framework and runtime for long-running, stateful agents." In practice you build a graph:

  • State is a schema whose keys are channels. Every node receives the current state.
  • Nodes are functions. They return a partial update (only the keys they changed), not the whole state.
  • Edges say what runs next. Normal edges are fixed; conditional edges call a routing function and go wherever it returns.
  • Reducers decide how an update merges. Without one, a new value overwrites the old one. With operator.add, lists are appended. For chat messages, use add_messages, which appends new messages and updates existing ones by ID.
  • A checkpointer saves the state after every step under a thread_id. That single feature gives you memory, pause and resume, human-in-the-loop and crash recovery.

MessagesState is a prebuilt state with one messages key that already uses add_messages. When you need more keys, subclass it or write your own:

import operator
from typing import Annotated, TypedDict

from langchain.messages import AnyMessage
from langgraph.graph.message import add_messages


class State(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]  # append chat messages
    notes: Annotated[list[str], operator.add]            # append strings
    attempts: int                                         # plain overwrite

You must call compile() before you can invoke or stream a graph. Compiling checks the structure and attaches the checkpointer.

What it isIn code
StateThe shared data every node reads and updatesA TypedDict, dataclass or Pydantic model; MessagesState for chat
NodeOne step of work: call a model, run tools, plain Pythonbuilder.add_node("agent", agent)
EdgeA fixed transition from one node to the nextbuilder.add_edge(START, "agent")
Conditional edgeA function that picks the next node at runtimebuilder.add_conditional_edges("agent", tools_condition)
ReducerHow a node's update merges into a state keyAnnotated[list, operator.add] or add_messages
CheckpointerSaves state after every step, per thread_idbuilder.compile(checkpointer=InMemorySaver())
InterruptPauses the graph until a human (or your app) resumes itinterrupt(payload), then Command(resume=value)

Step 1: Install LangGraph and Set Your API Key

You need Python 3.10 or newer (LangGraph's PyPI minimum). Use 3.11+ if you want to run the local dev server and Studio later, because the LangGraph CLI requires it.

We'll use uv, but pip install -U langgraph langchain "langchain[openai]" works the same. langgraph is the runtime. langchain gives you init_chat_model, message classes and the @tool decorator, and the [openai] extra installs the OpenAI integration. LangGraph doesn't require LangChain, but nearly every official example uses it for models and tools, and so do we.

Any provider works. For Anthropic, install "langchain[anthropic]", set ANTHROPIC_API_KEY and use an anthropic:... model string; for Gemini, "langchain[google-genai]" and GOOGLE_API_KEY.

terminal
uv init langgraph-tutorial
cd langgraph-tutorial
uv add langgraph langchain "langchain[openai]"

export OPENAI_API_KEY="sk-..."

Step 2: Build a Minimal Graph

Start with the smallest useful graph: one node that calls the model. init_chat_model takes a provider:model string, so switching providers is a one-line change.

Three things to notice:

  • The node returns {"messages": [reply]}, a partial update. Because MessagesState uses the add_messages reducer, the reply is appended to the conversation instead of replacing it.
  • START and END are special nodes. Every graph needs an edge out of START.
  • You can pass messages as plain dicts. LangGraph converts them to message objects.

Run it with uv run hello.py. The result is the final state, so result["messages"] holds both your question and the model's answer.

hello.py
from langchain.chat_models import init_chat_model
from langgraph.graph import StateGraph, MessagesState, START, END

model = init_chat_model("openai:gpt-5.5")


def chatbot(state: MessagesState):
    return {"messages": [model.invoke(state["messages"])]}


builder = StateGraph(MessagesState)
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": "Explain LangGraph in one sentence."}]}
)
result["messages"][-1].pretty_print()

Step 3: Add Tools and a ReAct Loop

Now the real agent: a support agent for an online store with two tools, lookup_order and issue_refund. This is the ReAct pattern (reason, act, observe, repeat), written as a graph:

  • bind_tools(tools) tells the model which tools exist, using each function's name, type hints and docstring as the schema.
  • ToolNode(tools) is a prebuilt node that runs every tool call in the last AI message and returns the results as ToolMessages.
  • tools_condition is a prebuilt routing function: it returns "tools" if the last message has tool calls, and END otherwise. That's why the tool node must be named "tools".
  • The edge from "tools" back to "agent" closes the loop. The model sees the tool results and decides whether to call more tools or answer.

Both ToolNode and tools_condition live in langgraph.prebuilt, and unlike create_react_agent they aren't deprecated. If you want to see the loop with no helpers at all, our LangChain vs LangGraph comparison writes the tool node and router by hand.

Save this as agent.py; the next steps import builder from it. The graph is compiled at module level so the dev server in a later section can find it.

agent.py
from langchain.chat_models import init_chat_model
from langchain.messages import SystemMessage
from langchain.tools import tool
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.prebuilt import ToolNode, tools_condition

ORDERS = {
    "A100": {"item": "Mechanical keyboard", "status": "delivered", "total": 129.00},
    "A101": {"item": "USB-C dock", "status": "in transit", "total": 89.50},
}


@tool
def lookup_order(order_id: str) -> str:
    """Look up an order's item, shipping status and total. order_id looks like 'A100'."""
    order = ORDERS.get(order_id)
    if order is None:
        return f"No order found with ID {order_id}."
    return f"{order_id}: {order['item']}, {order['status']}, total {order['total']:.2f} USD"


@tool
def issue_refund(order_id: str, amount: float) -> str:
    """Refund part or all of an order, in USD."""
    return f"Refunded {amount:.2f} USD for order {order_id}."


tools = [lookup_order, issue_refund]
model = init_chat_model("openai:gpt-5.5").bind_tools(tools)
SYSTEM = SystemMessage(
    content="You are a support agent for an online store. "
    "Always look an order up before answering questions about it. Keep replies short."
)


def agent(state: MessagesState):
    return {"messages": [model.invoke([SYSTEM] + state["messages"])]}


builder = StateGraph(MessagesState)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")

graph = builder.compile()

if __name__ == "__main__":
    result = graph.invoke(
        {"messages": [{"role": "user", "content": "Has order A101 shipped yet?"}]}
    )
    for message in result["messages"]:
        message.pretty_print()

Step 4: Add Memory With a Checkpointer

Right now every invoke starts from scratch. To make the agent remember a conversation, compile the same builder with a checkpointer and pass a thread_id in the config. LangGraph saves the state after every step under that thread; the next call with the same thread_id loads it first, and a new thread_id starts a fresh conversation.

  • InMemorySaver keeps checkpoints in RAM and loses them on restart. Use it for development and tests.
  • For production, use a durable saver: PostgresSaver from langgraph.checkpoint.postgres (package langgraph-checkpoint-postgres, with AsyncPostgresSaver for async code) or SqliteSaver (langgraph-checkpoint-sqlite).
  • graph.get_state(config) returns a snapshot of the thread, so you can inspect exactly what the agent knows.

A checkpointer is short-term, per-thread memory. For facts that should survive across threads (a user's preferences, say), LangGraph has stores: builder.compile(checkpointer=..., store=InMemoryStore()), read and written from your nodes.

memory.py
from langgraph.checkpoint.memory import InMemorySaver

from agent import builder

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "customer-42"}}

graph.invoke(
    {"messages": [{"role": "user", "content": "Hi, I'm Ana. My order is A100."}]},
    config,
)
result = graph.invoke(
    {"messages": [{"role": "user", "content": "What's my name, and did my order arrive?"}]},
    config,
)
result["messages"][-1].pretty_print()  # knows "Ana" and order A100

snapshot = graph.get_state(config)
print(len(snapshot.values["messages"]), "messages saved in this thread")

Step 5: Add Human-in-the-Loop With interrupt()

Letting a model issue refunds on its own is a bad idea. LangGraph's answer is interrupt(): call it anywhere in a node or a tool, and the graph saves its state, stops, and returns the payload you passed to it. Your app shows that payload to a human, then resumes the same thread with Command(resume=value). Inside the paused code, interrupt() returns that value.

First, change issue_refund in agent.py so it asks before acting:

from langgraph.types import interrupt


@tool
def issue_refund(order_id: str, amount: float) -> str:
    """Refund part or all of an order, in USD. A human must approve it."""
    decision = interrupt({"action": "issue_refund", "order_id": order_id, "amount": amount})
    if decision != "approve":
        return f"A human reviewer rejected the refund for {order_id}."
    return f"Refunded {amount:.2f} USD for order {order_id}."

Then run the graph with a checkpointer (interrupts need one, and you must resume with the same thread_id). With invoke, a pending interrupt shows up in the result under "__interrupt__".

Three rules from the LangGraph docs that save hours:

  1. The node restarts from the beginning on resume. Here the whole tools node runs again, so anything before interrupt() executes twice. Keep side effects (the actual refund API call) after it, or make them idempotent.
  2. Don't wrap interrupt() in a bare try/except. It pauses by raising a special exception, and a broad handler swallows it.
  3. Keep the order of interrupts deterministic. Resume values are matched to interrupt() calls by position within the node.

For the common case of "approve this tool call", LangChain's create_agent also offers HumanInTheLoopMiddleware. interrupt() is the general version: it works in any node, including mid-workflow review steps that have nothing to do with tools.

approve.py
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

from agent import builder

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "refund-7"}}

result = graph.invoke(
    {"messages": [{"role": "user", "content": "Order A100 arrived broken. Please refund it in full."}]},
    config,
)

if "__interrupt__" in result:
    print("Needs approval:", result["__interrupt__"][0].value)
    decision = input("approve / reject: ").strip()
    result = graph.invoke(Command(resume=decision), config)

result["messages"][-1].pretty_print()

Step 6: Stream Tokens and Updates

Agents are slow, and invoke makes users stare at a blank screen until the end. graph.stream() yields output while the graph runs. The stream_mode argument picks what you get:

  • "values": the full state after each step.
  • "updates": only what each node changed, as {node_name: update}. Best for progress UIs ("looking up order...").
  • "messages": LLM output token by token, as (message_chunk, metadata) tuples. metadata["langgraph_node"] tells you which node produced it. This works even though our node calls model.invoke(), not .stream().
  • "custom" (your own events via get_stream_writer), plus "checkpoints", "tasks" and "debug" for tooling.

Pass a list, like stream_mode=["updates", "messages"], and each item becomes a (mode, data) tuple. Since LangGraph 1.1 you can also pass version="v2" to get every chunk as a dict with type, ns and data keys, which is easier to handle when you mix modes. LangGraph 1.2 added an event-streaming API, graph.stream_events(..., version="v3"), which the docs now point new applications to. The classic stream() call below is still the default and works the same as before.

stream.py
from agent import graph

inputs = {"messages": [{"role": "user", "content": "What did I pay for order A100?"}]}

# 1. Progress: what each node changed
for chunk in graph.stream(inputs, stream_mode="updates"):
    for node_name, update in chunk.items():
        print(f"--- {node_name} ---")
        update["messages"][-1].pretty_print()

# 2. Tokens: the model's answer as it is generated
for token, metadata in graph.stream(inputs, stream_mode="messages"):
    if metadata["langgraph_node"] == "agent" and token.text:
        print(token.text, end="", flush=True)
print()

The Prebuilt Shortcut: create_agent

You just built a ReAct agent by hand. For many apps you don't have to. LangGraph v1 deprecated create_react_agent from langgraph.prebuilt; its replacement is LangChain's create_agent, which builds the same model → tools → model loop and returns a compiled LangGraph graph. Checkpointers, thread_id, streaming and interrupts all work the same way. The migration is mostly a rename: create_agent(model, tools, system_prompt=...) instead of create_react_agent(model, tools, prompt=...).

Extra behavior comes from middleware rather than extra nodes: retries, model fallbacks, summarizing long histories, call limits, PII redaction and tool approval (HumanInTheLoopMiddleware).

Rule of thumb: use create_agent when your agent is "a model with tools". Write a StateGraph when you need steps the loop can't express: a fixed validation step, branching by ticket type, parallel research, an approval gate in the middle of a workflow. You can combine them too: a create_agent agent can be one node of a bigger graph.

prebuilt.py
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

from agent import lookup_order, issue_refund

support_agent = create_agent(
    model="openai:gpt-5.5",
    tools=[lookup_order, issue_refund],
    system_prompt="You are a support agent for an online store. Keep replies short.",
    checkpointer=InMemorySaver(),
)

config = {"configurable": {"thread_id": "customer-42"}}
result = support_agent.invoke(
    {"messages": [{"role": "user", "content": "Has order A101 shipped yet?"}]},
    config,
)
result["messages"][-1].pretty_print()

Run It in LangSmith Studio and Deploy It

Studio (now called LangSmith Studio in the docs) is a free visual interface for your graph: you see the nodes, send messages, watch state change step by step, and answer interrupts from the UI. It runs against a local dev server:

  1. Install the CLI (it needs Python 3.11+): uv add "langgraph-cli[inmem]".
  2. Add a langgraph.json (below) next to agent.py. Each entry in graphs is "./file.py:variable" and points at a compiled graph.
  3. Put OPENAI_API_KEY and LANGSMITH_API_KEY in .env. Studio needs a LangSmith account, which is free to sign up for.
  4. Run uv run langgraph dev. The API starts at http://127.0.0.1:2024, with API docs at /docs, and the CLI prints a Studio link (https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024). Safari blocks localhost connections; use langgraph dev --tunnel there.

Notice we point the server at graph, compiled without InMemorySaver: the server manages persistence and threads itself.

Deploying. You have three realistic options:

  • LangSmith Deployment, LangChain's managed platform for stateful agents.
  • Self-hosted Agent Server: langgraph build -t my-image produces a Docker image of the API server. Running it in production needs a LangSmith license key, plus your own Postgres and Redis.
  • Plain Python: LangGraph is just a library. Wrap graph in FastAPI (or anything else), compile it with PostgresSaver, and deploy it like any other service. No license needed.
langgraph.json
{
  "dependencies": ["."],
  "graphs": {
    "support_agent": "./agent.py:graph"
  },
  "env": ".env"
}

When to Use LangGraph (and When Not To)

Use LangGraph when control flow is the hard part:

  • Your workflow mixes fixed steps and LLM decisions: validate input, let the model plan, run a required approval step, then execute.
  • You need branching, loops or parallel steps that don't fit a single model → tools loop.
  • You need human approval in the middle of a process, or runs that pause for hours and survive restarts.
  • You're building a multi-agent system and want to decide exactly who hands off to whom.

Skip it (or start higher up) when:

  • Your agent is a model with a few tools. Use create_agent. See LangChain vs LangGraph for side-by-side code.
  • Your problem reads like a team of specialists passing work down a line. CrewAI gets there with less code; see LangGraph vs CrewAI.
  • You want type-safe agents with validated structured output and little framework. PydanticAI fits better.

For the full landscape (PydanticAI, CrewAI, OpenAI Agents SDK, Google ADK, Claude Agent SDK, Mastra and more), read our AI agent frameworks guide.

Common Mistakes

  1. Copying a pre-1.0 tutorial. create_react_agent is deprecated, and so are MessageGraph and the old AgentState prebuilts. Custom StateGraph code, checkpointers, interrupt(), Command and Send work unchanged.
  2. Returning the whole state from a node. Return only the keys you changed. Returning state["messages"] again through add_messages won't duplicate messages (it updates them by ID), but with operator.add your lists will double.
  3. A list key without a reducer. Two nodes writing the same plain key in one step raises an error; give that key a reducer.
  4. Forgetting the thread_id. Memory, interrupts and get_state all need {"configurable": {"thread_id": ...}}. Use a stable ID per conversation.
  5. Using InMemorySaver in production. Every restart wipes every conversation. Switch to Postgres or SQLite before you ship.
  6. Side effects before interrupt(). They run again on resume.

Next Steps: The Best LangGraph Course and Beyond

You now have a LangGraph agent with tools, memory, human approval and streaming: the same building blocks production agents use. Where to go next:

  • Go deeper on LangGraph with the free LangChain Academy course picked above: subgraphs, map-reduce with Send, time travel and long-term memory with stores.
  • Learn the agent fundamentals in a lighter framework with our free 8-lesson PydanticAI course: tools, multi-agent systems, memory, testing and evals, and production deployment. The concepts carry straight back to LangGraph.
  • Compare every course worth your time in our best AI agent courses roundup, with free options and certificate details.

FAQ

Is LangGraph free?
Yes. LangGraph is open source under the MIT license, for both Python (langgraph) and JavaScript (@langchain/langgraph). You pay only for the model API calls your agent makes. LangSmith, used for tracing, Studio and managed deployment, is a separate product with a free account tier and paid plans.
What is the difference between LangGraph and LangChain?
LangGraph is the low-level runtime: graphs, state, checkpoints, interrupts and durable execution. LangChain is the higher-level agent framework on top of it, with create_agent, middleware and model integrations. create_agent returns a compiled LangGraph graph, so the two are layers of one stack rather than competitors.
Do I need LangSmith to use LangGraph?
No. Everything in this tutorial runs without LangSmith. You need a LangSmith account (free to sign up) only for Studio, tracing, or LangSmith Deployment. Tracing is opt-in through the LANGSMITH_TRACING and LANGSMITH_API_KEY environment variables.
Is there a JavaScript or TypeScript version of LangGraph?
Yes. LangGraph.js is the official JS/TS equivalent, installed with npm install @langchain/langgraph @langchain/core, plus a model integration package such as @langchain/anthropic. It has the same concepts: StateGraph, nodes, edges, checkpointers and interrupts.
Is LangGraph production ready?
Yes. LangGraph reached 1.0 with stable core APIs, and its README lists companies such as Klarna, Replit and Elastic among its users. For production, use a durable checkpointer like PostgresSaver instead of InMemorySaver, add tracing, and decide whether to self-host or use LangSmith Deployment.
What is the best LangGraph course?
LangChain Academy's free Introduction to LangGraph is the most complete: six hours of video from the LangGraph team, covering state, reducers, human-in-the-loop, time travel, memory and deployment. For a shorter start, DeepLearning.AI's AI Agents in LangGraph runs under two hours, but it predates LangGraph 1.0.

Read next

  1. 01LangChain vs LangGraph: Which One Should You Use?Same team, different layers. Side-by-side code for the same agent, the real differences in memory and human-in-the-loop, and a clear rule for when to use each.Comparison
  2. 02LangGraph vs CrewAI: Which Multi-Agent Framework Should You Use?The same two-agent research → write pipeline in both frameworks, how state, persistence, and human approval differ, a quick CrewAI tutorial, and what happened to AutoGen.Comparison
  3. 03AI Agent Frameworks: Which One Should You Use in 2026?Eight frameworks that matter, checked against their official docs: language, abstraction level, license, and a "pick this if" rule for each. Plus hello-world code so you can feel the API before you commit.Guide
  4. 04The Best Agentic AI Courses for Developers (2026)Sixteen AI agent courses for people who want to build agents, not just talk about them. Free options first, with certificate details for each one, picks for each framework, and an order to take them in.Best courses

Sources: LangGraph overview, LangGraph quickstart, LangGraph Graph API, LangGraph persistence, LangGraph interrupts, LangGraph streaming, LangGraph v1 migration guide, Run a local server (langgraph dev), LangChain models (init_chat_model), langgraph on PyPI, LangGraph on GitHub.