langchain-langgraph-basics
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".
Allowed Tools
Provided by Plugin
langchain-py-pack
Claude Code skill pack for LangChain 1.0 + LangGraph 1.0 (Python) - 34 skills covering chains, agents, RAG, middleware, checkpointing, HITL, streaming, and production patterns
Installation
This skill is included in the langchain-py-pack plugin:
/plugin install langchain-py-pack@claude-code-plugins-plus
Click to copy
Instructions
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 reachedfires 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
langgraphsilently reads oldPostgresSavercheckpoints
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
pathmap, compilation with a checkpointer, recursionlimit 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.
Prerequisites
- Python 3.10+
pip install langgraph>=1.0,<2.0 langchain-core>=1.0,<2.0- 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-postgresand 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, Command(update=...)
and node returns replace the field. The message-history reducer lives in
langgraph.graph.message:
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
import operator
class AgentState(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.getgraph().drawmermaid() —
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:
def plan(state: AgentState) -> dict:
# Returning a dict means "update these fields"
return {
"messages": [("assistant", "Plan: step 1, step 2, step 3")],
"scratchpad": ["planned_at_step_1"],
"step_count": state["step_count"] + 1,
}
def execute(state: AgentState) -> dict:
return {
"messages": [("assistant", f"Executed {state['step_count']} steps")],
"done": state["step_count"] >= 3,
}
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 import Literal
from langgraph.graph import StateGraph, START, END
# Router MUST return a value in the path_map keyset (P56)
def should_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
addconditionaledges 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 | 30-60 | 75 |
| Fan-out with N parallel branches that re-join | N + merge steps | 2 × max depth |
config = {
"configurable": {"thread_id": "user-42"}, # required for checkpointing (P16)
"recursion_limit": 30,
}
result = graph.invoke({"messages": [], "scratchpad": [], "step_count": 0, "done": False}, config)
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:
def run_agent(user_id: str, user_message: str) -> dict:
config = {
"configurable": {"thread_id": user_id},
"recursion_limit": 30,
}
assert config["configurable"].get("thread_id"), "thread_id required"
return graph.invoke(
{"messages": [("user", user_message)], "scratchpad": [], "step_count": 0, "done": False},
config,
)
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
TypedDictstate with reducer annotations on every list field- Node functions returning partial-state dicts, free of hidden side effects
- Conditional edges with
Literal-typed routers andENDin everypath_map - Compiled graph with a checkpointer matched to the deployment shape
recursion_limitsized from the superstep count table, not the default 25- Invocations with explicit
thread_idvalidated 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 |
| Old checkpoints read as empty state after upgrade | Schema change; PostgresSaver doesn't auto-migrate (P20) |
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.getgraph().drawmermaid() 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.
Resources
- LangGraph: Low-level concepts (StateGraph, reducers, nodes, edges)
- LangGraph: Persistence and checkpointing
- LangGraph:
addmessagesreducer - LangGraph:
addconditionaledgesAPI - LangGraph: Recursion limit and supersteps
- LangChain 1.0 release notes
- Pack pain catalog:
docs/pain-catalog.md(entries P16, P17, P18, P20, P55, P56)