LangGraph, building agents as a graph with durable state
LangGraph describes an agent as a graph: nodes do the work, edges decide what happens next, and shared state flows between them and gets saved after every step. A process interrupted by a crash resumes from where it stopped instead of starting over. Version 1.0 shipped alongside LangChain 1.0 on 17 October 2025, and the line has since reached 1.2.
How it differs from LangChain
LangChain gives you a working agent in one function and a loop that decides on its own when to stop. LangGraph takes that decision away from the model and hands it to you: you draw the flow, you set the transition conditions, you mark the points where the process pauses.
That difference has consequences. In a simple agent you do not know how many turns the model will take or in what order it will reach for tools. In a graph you do, because the flow lives in code and can be tested without calling a model at all. The price is that every graph has to be designed, which on a three step task is work you did not need to do.
It is also worth looking at this from the perspective of whoever maintains the result. An agent loop is legible to anyone who reads the prompt, while a graph requires reading code to know what will happen. In exchange it answers, unambiguously, why the process took one path rather than another, because the transition condition is written down. On a customer complaint, that difference decides whether diagnosis takes fifteen minutes or half a day of guessing what the model had in mind.
The practical boundary sits where a process stops fitting into one call. A conversation with a single tool belongs to LangChain. A process that waits for human approval, branches on a validation result, and has to survive a server restart belongs to LangGraph.
A graph is not the only way to describe such a process. LlamaIndex Workflows models the same thing with events: a step listens for an event of a given type and emits further ones itself, so adding a stage requires no redrawing of edges. The price runs exactly the other way from here, since the flow is not visible in one place and has to be reconstructed from event types scattered across steps, which on a customer complaint is harder than reading a graph.
Installation and your first graph
pip install langgraph langchain-openaiA minimal graph has three parts: a state schema, nodes, and edges.
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
question: str
chunks: Annotated[list[str], add]
answer: str
def search(state: State) -> dict:
return {"chunks": retriever.invoke(state["question"])}
def answer(state: State) -> dict:
context = "\n".join(state["chunks"])
return {"answer": model.invoke(f"{context}\n\nQuestion: {state['question']}").content}
graph = StateGraph(State)
graph.add_node("search", search)
graph.add_node("answer", answer)
graph.add_edge(START, "search")
graph.add_edge("search", "answer")
graph.add_edge("answer", END)
app = graph.compile()
app.invoke({"question": "how do returns work"})Every node is an ordinary function that receives state and returns a dictionary of changes. It does not have to return the whole state, only the fields it modifies. A small detail that simplifies a lot once you have ten nodes.
State, reducers, and typing
The Annotated[list[str], add] annotation in the state schema declares a reducer, meaning how a new value merges with the old one. Without it, each node overwrites the field. With it, values accumulate, which is what you need wherever several nodes contribute to a shared list.
The most common mistake at this stage is keeping too much in state. State is persisted after every step, so dropping whole documents into it instead of their identifiers raises write cost and checkpoint size. Keep what the next decision needs, and fetch the rest inside the node.
Branches are described by a function returning the name of the next node.
def is_enough(state: State) -> str:
return "answer" if len(state["chunks"]) >= 3 else "expand_query"
graph.add_conditional_edges("search", is_enough)A conditional function can also return a list of names, which fans out to several nodes in parallel and merges their results through the reducer. That helps when querying several sources at once, where asking them in sequence stretches the response for no reason.
The condition is plain Python, so you test it without network calls and without tokens. That is one of the better sides of this approach: control logic is testable separately from the model.
Durable execution and checkpointers
A checkpointer saves state after every step under a thread identifier. If the process dies halfway, you resume with the same identifier and the graph continues from the last saved node.
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(os.environ["DATABASE_URL"]) as saver:
app = graph.compile(checkpointer=saver)
app.invoke(
{"question": "how do returns work"},
config={"configurable": {"thread_id": "ticket-8842"}}
)Durability mode is set with the durability parameter. The value async writes state in the background while the next step runs, and is the default compromise. The value sync writes before the next step begins, so nothing is lost even if the process is killed outright, at a cost of tens of milliseconds per node. The value exit writes only at the end and suits short runs where resuming makes no sense.
The choice matters wherever a node performs a side effect. If a node sends a payment and state is written asynchronously afterwards, a restart at the wrong moment can repeat the operation. For nodes like that, set sync and add an idempotency key on your side.
PostgresSaver does not ship with the langgraph package but with a separate langgraph-checkpoint-postgres, so add it to your dependencies before copying the import above. On first run also call setup() on the checkpointer, since that is what creates the tables.
Local tests are fine with InMemorySaver from langgraph-checkpoint, which is a core dependency. Production wants Postgres, and a database from Neon or Supabase covers this without extra infrastructure, since the checkpointer needs only a handful of tables.
Human in the loop
Pausing a process on a human is part of the language in LangGraph, not a workaround. Calling interrupt stops execution, returns the data for review, and waits.
from langgraph.types import interrupt, Command
def approve_refund(state: State) -> dict:
decision = interrupt({"amount": state["amount"], "order": state["number"]})
if decision["action"] == "reject":
return {"status": "rejected", "reason": decision.get("reason", "")}
return {"status": "approved"}
app.invoke(Command(resume={"action": "approve"}), config=config)The process can sit there for hours, because state lives in a database rather than in process memory. You are free to restart the server, deploy a new version, or scale down in the meantime. You resume by supplying the same thread_id and the operator decision.
Two things deserve attention when designing this step. First, the data passed into the interrupt should be enough for the operator to decide without opening another system, otherwise approval turns into window switching. Second, plan for expiry: a process waiting three weeks for approval usually ought to close itself rather than hang in the database forever.
This mechanism is the main reason teams move from a simple agent to a graph. Every process touching money, personal data, or customer communication eventually gets an approval requirement, and bolting that onto an agent loop ends with a homegrown queueing system.
Multi agent topologies
Several agents can be wired together in a few ways, and the choice affects both cost and how hard failures are to diagnose.
| Topology | How it works | Advantage | Cost and risk |
|---|---|---|---|
| Single agent | One model, one tool set | Simplest diagnosis | At 15 tools the model starts picking wrong |
| Supervisor | One agent routes work to specialised subagents | Clear division of responsibility | An extra model call per handoff |
| Peer network | Agents hand tasks to each other directly | Shorter paths | Hard to predict the run or forecast cost |
| Pipeline | Fixed sequence of stages | Predictable time and price | No flexibility for unusual input |
Start with a single agent and split only when the tool set stops fitting into one prompt. Splitting into five agents in week one usually produces a system where nobody can say which of them made the bad call.
Streaming and run inspection
A graph streams at several levels: model tokens, state updates after each node, or full state snapshots. In a user interface the second mode is the most useful, since it lets you show which stage the process has reached.
for kind, data in app.stream(payload, config=config, stream_mode=["updates", "messages"]):
if kind == "updates":
print("node:", list(data.keys()))For diagnosis outside the interface there is LangSmith tracing, where each run appears as a tree of nodes with timings and token counts. Without it, debugging a twelve node graph comes down to reading logs, which stops working once branches appear.
How to test a graph
A graph beats an agent loop on testability, because it splits into layers, and each layer wants a different kind of test.
At the bottom sit the conditional functions. That is plain code with no model involved, so you cover it with unit tests and check the edge cases: an empty search result, a value sitting exactly on the threshold, a missing field in state. These run in milliseconds and catch most control flow bugs.
Above them are the nodes that call a model. Here you swap the model for a stub returning a fixed response and check that the node turns it into the right state change. This is about the contract, not answer quality, so a stub is enough.
At the top is the whole graph. You run it with InMemorySaver over a set of cases and compare the path it took against the expected list of nodes.
from langgraph.checkpoint.memory import InMemorySaver
def test_short_search_expands_the_query():
app = graph.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "test-1"}}
app.invoke({"question": "returns", "chunks": []}, config=config)
visited = [s.next for s in app.get_state_history(config)]
assert "expand_query" in str(visited)Interrupts deserve their own test. Cover both paths, resuming with approval and resuming with rejection, because in practice the rejection branch is the one left untested, and it is the one that strands a process in a state with no way out.
It also pays to keep a set of a dozen or so real queries with their expected end result and run it after every prompt change. That is no longer a unit test but a quality measurement, so run it deliberately, since it costs tokens.
Costs and LangSmith Deployment
The library itself is open source under the MIT licence and runs on your own server for free. The paid part is hosting for graphs with a task queue, resumption, and a run inspection panel. The name changed: what LangChain used to sell as LangGraph Platform appears in the price list as LangSmith Deployment, and the plans now cover tracing and deployments together.
| Plan | Seat cost | What it covers |
|---|---|---|
| Library on your own infrastructure | 0 USD | Full functionality, checkpointer on your own database |
| LangSmith Developer | 0 USD, one seat | 5k traces per month, no cloud deployments |
| LangSmith Plus | 39 USD per seat per month | 10k traces, one Serverless deployment in the Small size |
| LangSmith Enterprise | custom quote | Hybrid and self hosted deployment, SSO, SLA |
The seat rate is only the first half of the bill, and the table alone will not show it. Everything the agent actually consumes is metered separately in two units: LCU at 1.50 USD for work and compute, and LSU at 1 USD for traces and storage. The trace allowance included in a plan is not a hard ceiling but the point where pay as you go begins, so a month on a single seat can come to a multiple of that 39 USD. Cloud deployment also requires at least the Plus plan, and running the control plane in your own cluster requires Enterprise.
Token spend still dominates. A supervisor graph with three subagents makes anywhere from five to fifteen model calls per user request, so the gap between a large model and a smaller one in helper nodes changes cost several times over. Route only the genuinely hard decisions to the large model.
Common mistakes
The first is a graph without a step limit. A branch that loops back to an earlier node for some shape of data creates a cycle that runs until the budget is gone. Set recursion_limit and treat hitting it as a bug in the flow.
The second is state that grows without bound. A message list appended by a reducer on every turn will exceed the context window after a few dozen steps. Add a node that summarises or trims history.
The third is keeping secrets in state. Everything that enters state lands in the checkpoint database and in traces, so pass a user access token through configuration rather than through state fields.
The fourth is designing the graph around the current prompt. If nodes are named after fragments of a model instruction, every prompt change forces a structural change. Name nodes after business process steps, which change far less often.
When not to reach for LangGraph
| Situation | Better choice |
|---|---|
| One model call and a response | The provider SDK, for example OpenAI or Claude |
| Agent with a few tools and no pauses | create_agent from LangChain |
| Fixed sequence of background jobs | A task queue such as Celery or BullMQ |
| Business process with SLAs and retries | A workflow engine such as Temporal |
| Agent with branching, memory, and human approval | LangGraph |
The last row is the only one where the graph wins without reservations. Everywhere else it adds a layer you then have to maintain.
FAQ
Does LangGraph require LangChain?
No. LangGraph builds on langchain-core abstractions but does not force you to use LangChain chains or agents. A node can call a provider SDK directly, and the graph still supplies state, resumption, and pauses.
Does it work in TypeScript?
Yes, the library has a JavaScript and TypeScript version with the same model of graphs, state, and checkpointers. New features usually land in Python first, so for exotic integrations it is worth checking whether the equivalent exists yet. State typing on the TypeScript side works well and catches typos in field names.
How do I resume a process after a server restart?
Call the graph again with the same thread_id in the configuration. The checkpointer restores state from the last saved step and execution continues from the next node. The condition is a checkpointer on durable storage, since the in memory one disappears with the process.
Can I see which path the graph took?
Yes, every run leaves a state history available through get_state_history, and with tracing enabled also a node tree in LangSmith. State history also lets you rewind the process to a chosen step and run it down a different branch.
How many nodes is too many?
A graph beyond twenty nodes usually signals that one process is doing several things at once. Rather than growing it further, extract a subgraph and call it as a single node. A subgraph carries its own state and gets tested separately.
Durable execution documentation lives at docs.langchain.com, and the code in the GitHub repository.