LangGraph, the basics
LangGraph is a state machine runner with a save button. You write plain functions. You wire them into a graph. The library runs the graph, passes one shared state dict between the functions, and saves a copy of that state after every step. That is the whole product.
It does not call a model for you, it has no prompts, and it does not make an agent smarter. A node that calls an LLM is your code inside a function; LangGraph only decides which function runs next and remembers where the run is. It is an MIT-licensed Python library from LangChain Inc., with a JavaScript port (LangGraph.js). It reached 1.0 in October 2025; everything below was run on langgraph 1.2.17 in a python:3.12-slim container.
Why anyone needs that: an agent is a loop — call the model, maybe call a tool, look at the result, decide whether to go round again. Writing that loop yourself is ten lines. Making it survive a crash, stop for a human approval and continue tomorrow, show progress while it runs, and not spin forever is where the ten lines become a framework. LangGraph is that framework.
The four parts
| Part | What it is | In code |
|---|---|---|
| State | One dict shared by every node. Its schema is a TypedDict or Pydantic model. | class State(TypedDict): ... |
| Node | A function that reads the state and returns the keys it wants to change. | def write(state) -> dict |
| Edge | Which node runs next. Fixed, or chosen by a function at run time (conditional edge). | add_edge, add_conditional_edges |
| Checkpointer | Stores a snapshot of the state after every step, keyed by a thread_id. | compile(checkpointer=...) |
Two more words: a reducer says how a node’s return value merges into a key — overwrite (the default) or, say, append to a list. A thread is one conversation or job: every run with the same thread_id continues from the last saved snapshot.
A complete example
A writer node produces a draft. A conditional edge sends it back to try again until it has three drafts, then hands over to a review node that stops and waits for a human.
import operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import interrupt, Command
class State(TypedDict):
question: str
drafts: Annotated[list[str], operator.add] # reducer: append, don't overwrite
tries: int
def write(state: State) -> dict:
n = state["tries"] + 1
return {"drafts": [f"draft {n}"], "tries": n} # an LLM call would go here
def check(state: State) -> str:
return "review" if state["tries"] >= 3 else "write"
def review(state: State) -> dict:
ok = interrupt({"show": state["drafts"][-1]}) # pause, wait for a human
return {"drafts": [f"approved={ok}"]}
g = StateGraph(State)
g.add_node("write", write)
g.add_node("review", review)
g.add_edge(START, "write")
g.add_conditional_edges("write", check, ["write", "review"])
g.add_edge("review", END)
app = g.compile(checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "t1"}}
for ev in app.stream({"question": "q", "drafts": [], "tries": 0}, cfg, stream_mode="updates"):
print(ev)
print("next:", app.get_state(cfg).next)
for ev in app.stream(Command(resume=True), cfg, stream_mode="updates"):
print(ev)
print("final:", app.get_state(cfg).values)
Output:
{'write': {'drafts': ['draft 1'], 'tries': 1}}
{'write': {'drafts': ['draft 2'], 'tries': 2}}
{'write': {'drafts': ['draft 3'], 'tries': 3}}
{'__interrupt__': (Interrupt(value={'show': 'draft 3'}, id='4d81…', response_schema=None),)}
next: ('review',)
{'review': {'drafts': ['approved=True']}}
final: {'question': 'q', 'drafts': ['draft 1', 'draft 2', 'draft 3', 'approved=True'], 'tries': 3}
The first stream call returns at the interrupt. Nothing is waiting in memory: the run is a saved snapshot whose next node is review. The second call, possibly from another process, loads that snapshot and continues. drafts grew to four entries because of the operator.add reducer; tries was overwritten each time because it has none.
get_state_history). The run paused at step 3 with review due next.How a run actually executes
LangGraph runs in steps (the docs call them super-steps; the model is Google’s Pregel). In each step:
- Every node that is due runs. Nodes due in the same step run in parallel.
- Their returned dicts are merged into the state, key by key, through the reducers.
- A checkpoint is saved.
- Edges are evaluated to find the nodes due in the next step. None due → the run ends.
Rules that follow from this, each checked on 1.2.17:
- Two parallel nodes writing one key need a reducer. Without one, the step fails:
InvalidUpdateError: At key 'x': Can receive only one value per step. Use an Annotated key to handle multiple values.For chat history the stock reducer isadd_messages, which appends and replaces by message id. - Loops are capped. A graph that never reaches
ENDraisesGraphRecursionErrorafterrecursion_limitsteps. The default in 1.2.17 is 10 007 (overridable withLANGGRAPH_DEFAULT_RECURSION_LIMITor per call). Older releases and most tutorials say 25; set your own limit rather than trust either. - A checkpointer needs a thread. Calling a checkpointed graph without
thread_idraisesValueError: Checkpointer requires one or more of the following 'configurable' keys: thread_id, ….
What the checkpoints buy you
| Feature | How |
|---|---|
| Memory across turns | Invoke again with the same thread_id; the state is already there. |
| Human in the loop | interrupt(value) inside a node stops the run; Command(resume=x) continues it, and interrupt returns x. |
| Crash recovery | A failed run restarts from the last checkpoint, not from the beginning. |
| Time travel / debugging | get_state_history lists every snapshot; update_state edits one; run again from it. |
| Streaming | stream_mode="updates" yields each node’s changes as above; "messages" yields LLM tokens as they arrive; "values" the full state per step. |
Storage backends are separate packages: InMemorySaver (built in, gone on restart), langgraph-checkpoint-sqlite, langgraph-checkpoint-postgres. The state must be serializable, because it is written to that store on every step.
Where the LLM goes
Inside a node, like any other code. The usual agent is two nodes and one conditional edge:
- model — call the LLM with the message history and a list of tools; append its reply to
messages. - tools — run the tool calls in the last reply; append the results.
- edge — after
model: if the reply asked for tools, go totools; otherwiseEND. Aftertools: back tomodel.
That loop is shipped ready-made: ToolNode and tools_condition in langgraph.prebuilt, and LangChain 1.0’s create_agent builds exactly this graph. The model client can be anything — LangChain’s chat models, the raw Anthropic or OpenAI SDK, or an HTTP call to a local vLLM or Ollama server. Installing langgraph does pull in langchain-core (1.7.0 here) for its message types, but nothing forces you to use LangChain’s model wrappers.
When to use it, and when not
| Your app | Use |
|---|---|
| One prompt, one answer | Plain code. A graph adds nothing. |
| A fixed chain: retrieve, then generate | Plain code. Two function calls do not need a scheduler. (RAG basics) |
| A loop: model ↔ tools until done | A plain while loop works; LangGraph once you need the next rows. |
| Must pause for approval and resume later | LangGraph. Saved state + interrupt is the core feature. |
| Long jobs that must survive restarts | LangGraph with the Postgres checkpointer. |
| Several agents handing work to each other | LangGraph: each agent is a node or a subgraph, the shared state is the hand-off. |
Things that go wrong
- Side effects run twice after a resume. On resume, the interrupted node runs again from its first line; only the
interrupt()call returns the stored answer instead of stopping. In a test, a call counter in an interrupted node read 2 after one pause and one resume. Put emails, payments and writes after theinterrupt, or in their own node. InMemorySaverin production. Every thread is lost on restart, and nothing warns you. Use a database saver for anything that must outlive the process.- A list key without a reducer. Each node’s return replaces the list. The history quietly shrinks to the last node’s output.
- Growing state. Every step writes a full snapshot. A thread with thousands of messages, or large tool outputs kept in state, makes every step slower and the checkpoint table large. Keep blobs out of the state; store a reference.
- Reading the graph from the code. Conditional edges are functions, so the topology is only visible at run time.
app.get_graph().draw_mermaid()returns it as a Mermaid diagram.