Build a correct LangGraph 1.0 StateGraph — typed TypedDict state with reducers,
nodes, edges, compile, and recursion budgets — without hitting the silent-termination
and state-replacement traps. Use when writing your first LangGraph StateGraph,
diagnosing why a graph halted without reaching END, or picking recursion_limit.
Trigger with "langgraph statgraph", "langgraph basics", "GraphRecursionError",
"langgraph conditional edges".
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Build a correct LangGraph 1.0 StateGraph — typed TypedDict state with reducers,
nodes, edges, compile, and recursion budgets — without hitting the silent-termination
and state-replacement traps. Use when writing your first LangGraph StateGraph,
diagnosing why a graph halted without reaching END, or picking recursion_limit.
Trigger with "langgraph statgraph", "langgraph basics", "GraphRecursionError",
"langgraph conditional edges".
Designed for Claude Code, also compatible with Codex
LangChain LangGraph Basics (Python)
Overview
A conditional edge whose router returns a string that is not in path_map halts
the graph without reaching END. No exception. No log line. The invocation just
returns whatever state existed at the halt point — pain-catalog entry P56, and
the single most common reason a newly wired StateGraph "almost works." The
sibling pain: Command(update={"messages": [msg]}) wipes the prior message
history because messages was declared as a plain list[AnyMessage] instead of
Annotated[list[AnyMessage], add_messages] — the reducer is what turns update
into "append" instead of "replace" (P18).
Two more gotchas this skill defuses:
P55 — GraphRecursionError: Recursion limit of 25 reached fires on graphs
that never loop, because recursion_limit counts supersteps (one step
per synchronous batch of node executions), not loop iterations. A planner
executor + validator + summarizer can hit 25 without any cycle.
P20 — Upgrading langgraph silently reads old PostgresSaver checkpoints
as empty state. Checkpoint schemas evolve; PostgresSaver.setup() must be
rerun after every version bump before production traffic.
This skill walks through a minimal StateGraph end to end: a TypedDict state
with reducers on every list field, node functions that return partial-state
dicts, edges and defensive conditional edges with END as a fallback in
path_map, compilation with a checkpointer, recursion_limit sizing, and
invocation with an explicit thread_id. Pin: langgraph 1.0.x,
langchain-core 1.0.x. Pain-catalog anchors: P16, P18, P20, P55, P56.
A chat model (see langchain-model-inference), or a pure-logic graph with no LLM
For persistence beyond a single process: pip install langgraph-checkpoint-postgres and a Postgres 14+ instance
Instructions
Step 1 — Define state as a TypedDict with reducers on list fields
Every list-shaped field in state needs a reducer. Without one,
and node returns the field. The message-history reducer lives in
:
Command(update=...)
replace
langgraph.graph.message
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
import operator
classAgentState(TypedDict):
# Reducer "add_messages" appends + dedupes by message id (P18)
messages: Annotated[list[AnyMessage], add_messages]
# Plain list field also needs a reducer — use operator.add to concat
scratchpad: Annotated[list[str], operator.add]
# Scalars don't need a reducer; update replaces them
step_count: int
done: bool
If you forget the reducer on messages, a resume with
Command(update={"messages": [new_msg]}) will overwrite the entire prior
history. Validate reducers are in place with graph.get_graph().draw_mermaid() —
annotated fields render with their reducer name.
See State Reducers for the built-in list
(add_messages, operator.add, max, min) and how to write a custom merger
for non-trivial merge logic.
Step 2 — Write nodes as functions that return partial-state dicts
A node takes the full state and returns only the keys it wants to update. The
reducer handles merge:
Nodes must be deterministic on their inputs — LangGraph re-runs them during
time-travel replay, and a side-effecting node (DB write without idempotency key)
will double-fire. Push side effects to the checkpointer boundary or tool calls.
Step 3 — Wire edges and conditional edges defensively
from typing importLiteralfrom langgraph.graph import StateGraph, START, END
# Router MUST return a value in the path_map keyset (P56)defshould_continue(state: AgentState) -> Literal["execute", "end"]:
if state["done"] or state["step_count"] >= 10:
return"end"return"execute"
builder = StateGraph(AgentState)
builder.add_node("plan", plan)
builder.add_node("execute", execute)
builder.add_edge(START, "plan")
# path_map ALWAYS includes END as a fallback — if the router returns anything# else, the graph reaches END instead of halting silently (P56)
builder.add_conditional_edges(
"plan",
should_continue,
path_map={"execute": "execute", "end": END},
)
builder.add_edge("execute", "plan") # loop back to plan
The Literal return annotation on should_continue is a static guard — mypy
catches typos before runtime. path_map={"execute": "execute", "end": END}
is the spelled-out form; the compact form path_map=["execute", END] also works
when router return values match node names directly.
See Conditional Edges for all four
add_conditional_edges signatures, the path vs path_map distinction, and
a pytest pattern that asserts every router return value hits a known route.
Step 4 — Compile with a checkpointer
from langgraph.checkpoint.memory import MemorySaver
# MemorySaver is in-process — use PostgresSaver in production (P20)
checkpointer = MemorySaver()
graph = builder.compile(checkpointer=checkpointer)
For production, swap to langgraph.checkpoint.postgres.PostgresSaver. After
every langgraph version bump, run PostgresSaver.setup() in staging before
prod traffic — the schema evolves and old rows are silently read as empty state.
Step 5 — Pick recursion_limit for the graph's superstep count
recursion_limit defaults to 25. It is not a loop counter; it counts
total supersteps, and a superstep is one synchronous round of node
executions (parallel branches in the same step count as one). Typical shapes:
Graph shape
Supersteps per run
Suggested recursion_limit
Simple ReAct agent (plan → tool → observe → done)
6-12
15
Planner + executor + validator
12-25
30
Deep agent with sub-plans, reflection, branch merge
If you hit GraphRecursionError on a graph that clearly isn't looping (P55),
add print(state["step_count"]) at the entry of each node to see the actual
superstep count, then either raise the limit or restructure with a subgraph
so each subgraph gets its own budget.
See Recursion Limits for the full derivation
and a diagnostic script that traces superstep count at runtime.
Step 6 — Invoke with thread_id in config["configurable"]
Every invocation against a checkpointer-backed graph needs a thread_id in
config["configurable"]. Without it, each call gets a fresh state with no
warning (P16). Enforce it at your application boundary:
See First Graph Walkthrough for a
line-by-line annotation of a minimal 3-node graph that demonstrates typed
state, a reducer, and a conditional edge to END.
Decision Tree
Is your field a list you want to append?
-> Annotate with add_messages (for messages) or operator.add (for plain lists)
Is your router adding a new output string?
-> Add that string to path_map BEFORE deploying; include END as a fallback
Hitting GraphRecursionError on a non-looping graph?
-> supersteps != iterations; raise recursion_limit to 50 or split into subgraphs
Upgraded langgraph minor version?
-> Re-run PostgresSaver.setup() in staging before routing prod traffic
Multi-turn agent forgets between calls?
-> thread_id missing from config["configurable"] — enforce at boundary
Output
TypedDict state with reducer annotations on every list field
Node functions returning partial-state dicts, free of hidden side effects
Conditional edges with Literal-typed routers and END in every path_map
Compiled graph with a checkpointer matched to the deployment shape
recursion_limit sized from the superstep count table, not the default 25
Invocations with explicit thread_id validated at the app boundary
Error Handling
Error
Cause
Fix
GraphRecursionError: Recursion limit of 25 reached
Supersteps counter, not loops (P55)
Raise recursion_limit or split into subgraphs; add per-node logging to count actual steps
Graph halts without reaching END, no error
Router returned a value not in path_map (P56)
Type router as Literal[...]; include END as a default key in path_map
Command(update={"messages": [msg]}) wipes history
Missing reducer on list field (P18)
Annotate as Annotated[list[AnyMessage], add_messages]
Multi-turn memory resets between calls, no warning
Missing thread_id in config (P16)
Assert config["configurable"]["thread_id"] at app boundary
Run PostgresSaver.setup() in staging after every langgraph bump
TypeError: Object of type datetime is not JSON serializable at interrupt
Non-primitive in state (P17)
Keep state primitives-only; serialize complex types to ISO strings
Node runs twice during replay
Time-travel re-executes deterministic nodes
Push side effects to tools or the checkpointer write boundary
Examples
Building a minimal linear graph (no loop)
Three nodes (plan → execute → summarize), no conditionals, one reducer on
messages. Runs in 4 supersteps (START counts as one). Safe at default
recursion_limit=25, but set it to 10 explicitly so readers see the budget.
See First Graph Walkthrough for the
line-by-line annotation and the graph.get_graph().draw_mermaid() output.
Adding a conditional loop with a bounded retry budget
A validator node that either completes ("end" → END) or retries
("retry" → back to executor), bounded by step_count >= 3. The router is
Literal["retry", "end"] and path_map maps both. Caps at 7 supersteps, so
recursion_limit=15 is plenty of headroom.
See Conditional Edges for the full example
and the pytest that iterates every Literal branch.
Diagnosing a mystery GraphRecursionError
A planner that fans out to 4 parallel executors, then a validator, then a
summarizer. Looks linear in the mermaid diagram, hits
GraphRecursionError: Recursion limit of 25 reached on 10% of runs. Cause:
each parallel executor counts as its own superstep branch when the merge node
is conditional. Fix: raise to 50 or wrap the fan-out in a subgraph.
See Recursion Limits for the diagnostic
script and the subgraph refactor.