Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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".
.claude/skills/jeremylongshore-langchain-langgraph-basics/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 90% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 202% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 134% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 125% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 122% | 0% |
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:
GraphRecursionError: Recursion limit of 25 reached fires on graphsthat never loop, because recursion_limit counts supersteps (one step per synchronous batch of node executions), not loop iterations. A planner
langgraph silently reads old PostgresSaver checkpointsas 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.
pip install langgraph>=1.0,<2.0 langchain-core>=1.0,<2.0langchain-model-inference), or a pure-logic graph with no LLMpip install langgraph-checkpoint-postgres and a Postgres 14+ instanceTypedDict with reducers on list fieldsEvery 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:
pythonfrom 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.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.
A node takes the full state and returns only the keys it wants to update. The reducer handles merge:
pythondef 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.
pythonfrom 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 add_conditional_edges signatures, the path vs path_map distinction, and a pytest pattern that asserts every router return value hits a known route.
pythonfrom 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.
recursion_limit for the graph's superstep countrecursion_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 |
pythonconfig = { "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.
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:
pythondef 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.
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 boundaryTypedDict state with reducer annotations on every list fieldLiteral-typed routers and END in every path_maprecursion_limit sized from the superstep count table, not the default 25thread_id validated at the app boundary| 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 |
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.
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.
GraphRecursionErrorA 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.
add_messages reduceradd_conditional_edges APIdocs/pain-catalog.md (entries P16, P17, P18, P20, P55, P56)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 20,208 | 16,410 | -19% | 1 | 1 | 0% | 2,918 | 5,588 | +92% | 0 | 0 | — |
case-02 | fail→pass | 30,123 | 25,366 | -16% | 1 | 1 | 0% | 3,848 | 7,324 | +90% | 0 | 0 | — |
case-03 | pass→pass | 13,591 | 12,074 | -11% | 1 | 1 | 0% | 1,620 | 4,722 | +191% | 0 | 0 | — |
case-04 | pass→pass | 13,332 | 11,185 | -16% | 1 | 1 | 0% | 1,502 | 4,545 | +203% | 0 | 0 | — |
case-05 | pass→pass | 20,966 | 18,291 | -13% | 1 | 1 | 0% | 2,747 | 5,844 | +113% | 0 | 0 | — |
case-06 | fail→pass | 14,440 | 11,178 | -23% | 1 | 1 | 0% | 1,485 | 4,489 | +202% | 0 | 0 | — |
case-07 | pass→pass | 10,397 | 11,283 | +9% | 1 | 1 | 0% | 976 | 4,661 | +378% | 0 | 0 | — |
case-08 | fail→pass | 16,949 | 22,576 | +33% | 1 | 1 | 0% | 2,769 | 6,466 | +134% | 0 | 0 | — |
case-09 | pass→pass | 21,002 | 22,526 | +7% | 1 | 1 | 0% | 2,588 | 5,988 | +131% | 0 | 0 | — |
case-14 | pass→pass | 16,648 | 16,190 | -3% | 1 | 1 | 0% | 1,965 | 5,047 | +157% | 0 | 0 | — |
case-10 | fail→pass | 20,183 | 12,225 | -39% | 1 | 1 | 0% | 2,517 | 5,659 | +125% | 0 | 0 | — |
case-11 | fail→pass | 19,446 | 19,082 | -2% | 1 | 1 | 0% | 2,653 | 5,880 | +122% | 0 | 0 | — |
case-12 | pass→pass | 18,546 | 15,410 | -17% | 1 | 1 | 0% | 2,726 | 5,794 | +113% | 0 | 0 | — |
case-13 | pass→pass | 20,451 | 24,166 | +18% | 1 | 1 | 0% | 2,953 | 6,234 | +111% | 0 | 0 | — |
case-15 | pass→pass | 12,834 | 26,903 | +110% | 1 | 1 | 0% | 2,304 | 6,798 | +195% | 0 | 0 | — |
case-16 | pass→pass | 22,452 | 15,991 | -29% | 1 | 1 | 0% | 2,968 | 5,711 | +92% | 0 | 0 | — |
case-17 | pass→pass | 17,787 | 11,678 | -34% | 1 | 1 | 0% | 2,159 | 5,482 | +154% | 0 | 0 | — |
case-18 | pass→pass | 16,311 | 11,218 | -31% | 1 | 1 | 0% | 1,875 | 4,522 | +141% | 0 | 0 | — |
case-19 | fail→fail | 21,557 | 16,472 | -24% | 1 | 1 | 0% | 2,316 | 5,395 | +133% | 0 | 0 | — |
case-20 | pass→pass | 6,530 | 15,699 | +140% | 1 | 1 | 0% | 1,316 | 5,315 | +304% | 0 | 0 | — |
case-21 | pass→pass | 16,607 | 12,066 | -27% | 1 | 1 | 0% | 2,258 | 5,716 | +153% | 0 | 0 | — |
case-22 | pass→pass | 21,407 | 13,684 | -36% | 1 | 1 | 0% | 3,119 | 6,071 | +95% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.