Guide

LangGraph tutorial for beginners: your first graph

To build a first LangGraph workflow, define a state type, write Python functions as nodes, connect them with edges on a StateGraph, compile the graph and call invoke. This tutorial builds a small draft-and-check loop in plain Python, so you can learn the mechanics without a model or an API key.

What you will build

LangGraph describes a workflow as a graph. Shared state holds the data for a run, nodes do the work and edges decide what comes next. The model becomes useful when a workflow has branches, loops or pauses that would be awkward to express as a straight sequence.

Our example produces a headline for a topic, checks it and tries again if it fails, up to a fixed limit:

START --> write --> check --(passed)--> END
            ^         |
            +--(retry)+
                      |
                      +--(no attempts left)--> END

This diagram is an illustrative sketch of the flow rather than code. The writing step is an ordinary function that stands in for a model call, so everything runs on your machine with no account. Once you understand the mechanics, replacing that function with a real LLM call is a small change.

This tutorial is about the first graph and how the pieces fit together. For design decisions on a larger workflow, read building an orchestrator with LangGraph. If you are unsure whether you need a graph at all, start with LangChain vs LangGraph.

Step 1: install LangGraph

Create a virtual environment for the project, then install the package:

pip install -U langgraph

This shell command is illustrative of a basic setup. The official quickstart installs further packages because its example uses a real model, which we do not need here. The samples below follow the API shown in the LangGraph documentation at the time of writing, but libraries change, so check the current documentation for the version you install.

Step 2: define the state

State is the data that passes through the graph. A TypedDict is a straightforward way to declare it. The sample in this step, like those in the following steps, is illustrative, and together they form one small script.

from typing import TypedDict

class HeadlineState(TypedDict):
    topic: str
    headline: str
    attempts: int
    passed: bool

Think of this as a contract between the nodes: each field has one meaning, and every node can rely on it. The attempts field is control data. It has nothing to do with the content, but it will stop our loop from running forever.

Step 3: write the nodes

A node is a function that receives the current state and returns a dictionary of the fields it wants to change. It does not need to return the whole state.

MAX_LENGTH = 30

def write(state: HeadlineState):
    attempts = state["attempts"] + 1
    if attempts == 1:
        text = f"Everything you could ever need to know about {state['topic']}"
    else:
        text = f"A guide to {state['topic']}"
    return {"headline": text, "attempts": attempts}

def check(state: HeadlineState):
    return {"passed": len(state["headline"]) <= MAX_LENGTH}

The first attempt is deliberately too long, so that you can watch the loop work. In this illustrative code, write imitates a model that improves on its second try, and check applies a simple rule. Because both are plain functions, you can test them by passing a dictionary, with no graph involved.

Step 4: add a routing function

A routing function looks at the state and returns the name of what should run next. It should make a decision and nothing else, leaving any updates to the nodes. The following illustrative function uses LangGraph's END marker, which means the run is finished.

from langgraph.graph import END

MAX_ATTEMPTS = 3

def route_after_check(state: HeadlineState):
    if state["passed"]:
        return END
    if state["attempts"] >= MAX_ATTEMPTS:
        return END
    return "write"

Two different outcomes reach END here, and that is fine for a first graph, because the caller can read passed to see which one occurred. A real workflow might send repeated failures to a person for review instead. The guide to human-in-the-loop approval covers that pattern.

Step 5: build and compile the graph

Now connect the parts. This illustrative sample registers the nodes, adds the fixed edges, attaches the conditional edge and compiles the result.

from langgraph.graph import StateGraph, START

builder = StateGraph(HeadlineState)

builder.add_node("write", write)
builder.add_node("check", check)

builder.add_edge(START, "write")
builder.add_edge("write", "check")
builder.add_conditional_edges("check", route_after_check, ["write", END])

graph = builder.compile()

Here is what each part does:

  • StateGraph(HeadlineState) creates a builder for graphs that use your state type.
  • add_node gives a function a name, which edges and routing functions then use.
  • add_edge creates a fixed transition. START marks where a run enters the graph.
  • add_conditional_edges calls your routing function after the check node. The list states the destinations it may return.
  • compile turns the builder into a graph you can run.

Step 6: run it

Call invoke with the initial state. This illustrative snippet starts with empty values for the fields the nodes will fill in:

result = graph.invoke({
    "topic": "vector search",
    "headline": "",
    "attempts": 0,
    "passed": False,
})
print(result)

The run writes the long headline and fails the check, then loops back. The second attempt, "A guide to vector search", is short enough to pass, so the graph finishes with attempts at 2 and passed set to true. Change MAX_LENGTH to 5 and it will instead stop at the attempt limit with passed set to false. Trying both outcomes is a quick way to see how routing depends on state.

To see the individual steps, use graph.stream with the same input. This yields progress as the run moves from node to node, which is helpful when you are working out why a graph took a particular path.

Test the parts without the graph

One advantage of this design is that most of your tests never need to run the graph. Call check with a dictionary holding a short headline and confirm that passed is true. Call route_after_check three times, once for each outcome: passed, failed with attempts left and failed with none left. Those checks are fast and need no network, and they will still be useful once a real model sits inside write. Then keep one or two tests that run the compiled graph from start to finish, to confirm that the names and edges are connected as you intended.

Step 7: add a checkpointer

So far, the state disappears when the run ends. A checkpointer saves it as the graph runs, under a thread ID, and this is what makes features such as multi-turn memory, resuming and pausing for approval possible. This illustrative sample uses the in-memory version from the documentation:

from langgraph.checkpoint.memory import InMemorySaver

saved_graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "demo-1"}}

saved_graph.invoke({
    "topic": "vector search",
    "headline": "",
    "attempts": 0,
    "passed": False,
}, config)

print(saved_graph.get_state(config).values)

Using the same thread ID later reaches the same saved state, while a different ID starts separately. In-memory checkpoints vanish when the process restarts, so they suit learning and tests. The documentation recommends a persistent checkpointer for production. Remember that whatever you place in state may then be written to storage, so keep secrets out of it. Agent memory explains what is worth keeping.

Step 8: replace the stand-in with a model

To make the graph useful, change the body of write so that it calls a model through your preferred client, then return the text in the same field. The structure of the graph does not need to change. That separation is a real benefit: control flow lives in the graph and model behaviour lives in the node.

Keep a few things in mind when you make the change:

  • Validate model output before you route on it. If a node classifies something, check the label against the allowed values and choose a safe default for anything else.
  • Keep the loop limit. Model-driven loops may not converge, and every pass costs money and time. LangGraph also has a recursion limit that stops runaway runs, but your own limit lets you decide what happens next.
  • Handle failures deliberately. Decide what a node should do about timeouts and provider errors.
  • Keep tests cheap. Replace model calls with stubs when testing routing, as this tutorial effectively does.

Common beginner mistakes

  • Mutating state and returning nothing useful. Return the updates from the node. Do not rely on changing the input dictionary in place.
  • Expecting lists to accumulate. A returned value overwrites the existing one by default. To append, annotate the field with a reducer, for example Annotated[list[str], operator.add], as the official quickstart does for its messages.
  • Mismatched names. A routing function must return a destination that the graph knows about, so a small spelling error can break a run.
  • Omitting initial values. Our nodes read attempts directly, so a run that leaves it out will raise a KeyError.
  • Building a graph you do not need. If the workflow is a straight line of two calls, ordinary functions may be clearer.

Where to go next

Try adding a second route, then a step that requires a person's approval. After that, look at how tools fit in with tool calling for AI agents. If you are planning a production workflow, the orchestration with LangGraph page describes what I can help with.

Frequently asked questions

Do I need an LLM to use LangGraph?

No. A node can be any suitable Python function, so a graph can run with no model at all. This makes it easier to learn the mechanics and to test control flow before adding model calls.

What is the difference between a node and an edge?

A node does the work: it reads the state and returns updates. An edge decides what happens next. A normal edge always leads to the same node, whereas a conditional edge uses a function to choose.

Why does a node return only some of the state?

LangGraph merges a node's returned values into the shared state, so a node needs to return only what it changed. By default a new value overwrites the old one, unless the field has a reducer.

Is InMemorySaver suitable for production?

No. It keeps checkpoints in memory, so they are lost when the process restarts. The LangGraph documentation recommends a persistent checkpointer for production, such as the PostgreSQL option.