The Complete Guide to LangGraph Errors in 2026 — AI Error Hub
AI Error Hub / Guides / LangGraph
Pillar guide · #7 of 10

The Complete Guide to LangGraph Errors in 2026

The definitive reference for engineers building stateful agent workflows on LangGraph after the 1.0 GA. Covers StateGraph construction, state schemas with reducers, the Command routing pattern, checkpointers (MemorySaver, Postgres, Redis, SQLite), interrupts for human-in-the-loop, subgraphs, streaming modes, and LangGraph Platform deployment. Links to every one of our 30 LangGraph error-fix pages.

Chapter 01

Introduction

LangGraph reached 1.0 GA on October 22, 2025, at the same time as LangChain 1.0. Where LangChain is a composition layer, LangGraph is the stateful orchestration runtime underneath — nodes, edges, state, checkpointers, interrupts. In 2026 it's the fastest-growing agent framework by GitHub stars (30,000+), sits underneath every create_agent call in LangChain, and is the recommended path when you need branching, long-running workflows, human-in-the-loop approval gates, or multi-actor coordination beyond what create_agent's middleware model handles.

This guide is the definitive reference for LangGraph as it exists today. It covers StateGraph construction, state schemas and reducers, the Command routing pattern, conditional edges, the checkpointer library (in-memory, Postgres, Redis, SQLite), interrupt() for HITL, subgraphs and multi-actor patterns, streaming modes, migration off deprecated prebuilts, and LangGraph Platform for hosted deployment.

Who this guide is for

The guide assumes you have:

  • Built at least one working LangChain or LangGraph app
  • Understand why create_agent exists (see the LangChain pillar) and hit a case where its middleware model isn't enough
  • Reached a workflow with branching, retries, human approval, or multi-actor coordination that a linear chain can't express

If that's you, read this end-to-end once to build a full map of the framework surface; return to specific chapters when you hit a related error. Each chapter links to the specific error-fix pages that answer narrower questions with tested code.

What LangGraph is (and when to reach for it)

LangGraph in 2026 is a graph-based orchestration runtime for agent workflows. You describe your workflow as a directed graph of nodes with typed state flowing between them; LangGraph handles execution, persistence, streaming, and recovery. Where create_agent hides this graph underneath a single agent-loop abstraction, LangGraph lets you construct any graph shape:

  • Nodes — functions that read and update state
  • Edges — connections between nodes (fixed or conditional)
  • State — typed shared data flowing through the graph, updated via reducers
  • Checkpointers — persistence for state at every node boundary
  • Interrupts — pause the graph mid-execution, wait for external resume
  • Subgraphs — nested graphs with their own state, callable as nodes

LangChain's 2026 State of Agent Engineering report ties over 60% of production incidents to state management. That's the niche LangGraph fills — graph-native state is a much stronger primitive than "pass a big dict around and hope."

When to use LangGraph vs create_agent vs raw SDK

Workflow shape Best fit
Simple model + tools loop, no branching LangChain create_agent
Same, plus middleware (fallback, summarization, PII redaction) LangChain create_agent + middleware
Branching workflow: research → draft → approve → publish or revise LangGraph StateGraph
Multi-agent handoff (supervisor delegates to specialist agents) LangGraph with subgraphs
Long-running workflow that pauses days for human approval LangGraph + durable checkpointer
Parallel fan-out and aggregation across many tasks LangGraph with parallel edges
Very simple single provider call, no loop Raw provider SDK — skip both frameworks

What changed in 1.0 and after

1. create_react_agent from langgraph.prebuilt deprecated. The prebuilt was the recommended entry point in early 2025; in v1 it's deprecated in favor of create_agent in the langchain package. Same underlying LangGraph runtime, cleaner API, middleware system. See the LangChain pillar for the migration path.

2. Semver commitment through 2.0. LangGraph 1.x has committed to no breaking changes until 2.0. In practice this means new features arrive in minor versions, deprecations get formal notice periods, and pinning is less fraught than in the 0.x era.

3. entrypoint and task decorators for the functional API. An alternative to StateGraph for simpler workflows — decorate functions with @entrypoint and @task, get LangGraph's persistence, streaming, and interrupts without hand-building a graph.

4. LangGraph Platform GA. The hosted deployment layer for LangGraph applications — persistent threads, cron jobs, webhooks, assistants API. Deploys via langgraph.json config file plus the langgraph-cli. Chapter 11 covers it.

5. Streaming modes expanded. stream_mode now supports "values" (full state after each step), "updates" (incremental state changes), "messages" (message-level streaming for chat), "debug", and combined tuples. Plus subgraphs=True to include events from nested subgraphs.

What's not in this guide

  • LangChain's create_agent and middleware — see the LangChain pillar. This guide references LangChain but doesn't teach it.
  • Prompt engineering or model selection — framework-level content
  • Provider-specific quirks — for those see the model pillars (Claude, OpenAI, Gemini)
Version reference for this guide

All examples verified against langgraph 1.2.7, langchain-core 1.4.x, langchain 1.2.10, and checkpointer packages at their August 2026 versions (langgraph-checkpoint-postgres 2.0.x, langgraph-checkpoint-redis 0.1.x, langgraph-sdk 0.3.x). Pin your versions.

Chapter 02

Core concepts — graphs, state, checkpoints

LangGraph's core primitives map closely to graph theory: StateGraph is the builder, nodes are functions, edges are transitions, state is the data flowing through. Checkpointers add persistence at every node boundary. Understanding this handful of primitives is enough to build almost any workflow — the rest of the framework is refinement.

The minimum working graph

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    counter: int

def increment(state: State) -> dict:
    return {"counter": state["counter"] + 1}

def double(state: State) -> dict:
    return {"counter": state["counter"] * 2}

builder = StateGraph(State)
builder.add_node("increment", increment)
builder.add_node("double", double)
builder.add_edge(START, "increment")
builder.add_edge("increment", "double")
builder.add_edge("double", END)

graph = builder.compile()

result = graph.invoke({"counter": 5})
# {"counter": 12}   ← 5 + 1 = 6, then 6 * 2 = 12

Five moves: define state schema, define node functions, add nodes to the builder, wire edges, compile. graph.invoke() runs the graph to completion.

The primitives at a glance

Primitive Purpose
StateGraph(state_schema) The builder. Instantiate with a state schema (TypedDict or Pydantic).
add_node(name, fn) Register a node function under a name.
add_edge(from, to) Fixed directional edge between two nodes.
add_conditional_edges(from, router_fn, mapping) Dynamic routing based on a function's return value.
START, END Reserved node names — graph entry and terminal points.
set_entry_point(name) Alternative to add_edge(START, name).
.compile(checkpointer=..., interrupt_before=[...], interrupt_after=[...]) Compile the builder into an executable graph.
graph.invoke(input, config) Run to completion. Returns final state.
graph.stream(input, config, stream_mode=...) Stream state updates as the graph runs.
graph.get_state(config), graph.get_state_history(config) Read current state and history for a thread.

Threads & config

A thread is a persistent run identifier. With a checkpointer configured, all state for a given thread_id is persisted; subsequent invocations with the same thread_id resume from the last checkpoint.

from langgraph.checkpoint.memory import MemorySaver

memory = MemorySaver()
graph = builder.compile(checkpointer=memory)

config = {"configurable": {"thread_id": "conv-42"}}

# First run
graph.invoke({"counter": 5}, config)

# Second run — same thread_id continues where the last left off
snapshot = graph.get_state(config)
print(snapshot.values)   # {"counter": 12}

graph.invoke({"counter": snapshot.values["counter"]}, config)   # continues thread

Every invocation needs a config with configurable.thread_id when checkpointing is enabled. Miss it and you get an error about missing thread_id. See thread_id missing.

The functional API — @entrypoint and @task

For simpler workflows, the functional API replaces StateGraph construction:

from langgraph.func import entrypoint, task
from langgraph.checkpoint.memory import MemorySaver

@task
def step_1(query: str) -> str:
    return f"{query} - processed"

@task
def step_2(processed: str) -> str:
    return f"{processed} - finalized"

@entrypoint(checkpointer=MemorySaver())
def workflow(query: str) -> str:
    p = step_1(query).result()
    return step_2(p).result()

result = workflow.invoke("hello", {"configurable": {"thread_id": "t-1"}})

@entrypoint registers the top-level function. @task-decorated functions get automatic memoization within a thread — if the workflow crashes and resumes, tasks that already completed don't rerun. Use the functional API when your workflow reads like straight code with a few async points; drop to StateGraph when you need branching, conditional edges, or subgraphs.

What "state" actually is

State is a typed dict-like object. Each node receives the current state, returns a partial update, and LangGraph merges the update into the state via reducer functions (chapter 3). The state schema is the contract between nodes: any field a node reads must be declared; any field a node writes must be in the schema.

class ChatState(TypedDict):
    messages: list
    user_id: str
    turn_count: int

def add_greeting(state: ChatState) -> dict:
    return {
        "messages": state["messages"] + [{"role": "assistant", "content": "Hi!"}],
        "turn_count": state["turn_count"] + 1,
    }

Without a reducer, returning a field means overwrite. That works for scalars (counter, turn_count) but is wrong for lists (messages) where you want append. Chapter 3 covers reducers.

Compile-time vs runtime

.compile() finalizes the graph structure. After compile, you can't add nodes or edges. What is configurable at runtime: the checkpointer connection details, interrupt behavior, streaming mode. If you need dynamic graph structure (nodes changing per-request), you're using LangGraph wrong — either write branching logic inside nodes, or use subgraphs, or drop to a lower layer.

Compile once, reuse across invocations

In production, compile the graph at module load (or worker startup), not per-request. Compilation isn't expensive but it isn't free, and it clears the internal graph state. Pattern: build and compile in a module-level function; share the compiled graph across requests; pass a fresh config per invocation.

Checkpointers — the persistence layer

A checkpointer serializes the full graph state after every node execution. Enables three things:

  1. Resume-from-failure — crash, restart, invoke with the same thread_id, graph picks up from the last checkpoint
  2. Human-in-the-loop — pause via interrupt(), persist state, wait for external resume signal
  3. Time travel — inspect state history, fork from a past checkpoint, replay with modifications

Chapter 7 walks each checkpointer (in-memory, Postgres, Redis, SQLite) and the granularity trade-offs.

Chapter 03

State schemas & reducers

The state schema is your graph's contract. Get it wrong and every node fights the framework. Get it right and the rest of the graph becomes obvious. This chapter covers TypedDict vs Pydantic, reducer functions, the MessagesState convenience, and the update-vs-merge semantics that trip up every team on their first list-typed field.

TypedDict vs Pydantic

Schema type Pros Cons
TypedDict Zero runtime overhead; simple; direct dict access; matches LangGraph internals closely No runtime validation; typos on field names silently succeed
Pydantic BaseModel Runtime validation; automatic type coercion; better IDE support; serialization Small overhead per state update; slightly more setup; needs Pydantic v2

Recommendation: TypedDict for prototypes and simple graphs where you own every node. Pydantic when: state crosses team boundaries, validation catches bugs early, or you need serialization to external systems. Both work; pick one per graph and stick with it. See TypedDict vs Pydantic.

The default: overwrite semantics

Without a reducer, returning a field overwrites its current value. This is fine for scalars:

class State(TypedDict):
    counter: int
    status: str

def update(state: State) -> dict:
    return {"counter": state["counter"] + 1, "status": "processing"}
# Merged: counter overwritten with new value, status overwritten

But it's almost always wrong for lists — you want append, not replace:

class BadState(TypedDict):
    messages: list

def add_message(state: BadState) -> dict:
    return {"messages": [{"role": "user", "content": "hi"}]}
# BUG: overwrites the entire messages list with just this one message!

To append, either return the full new list (state["messages"] + [new_msg]) or — much better — declare a reducer on the field.

Reducers via Annotated

from typing import Annotated, TypedDict
from operator import add

class State(TypedDict):
    counter: int
    messages: Annotated[list, add]   # ← reducer: concat lists on update

def add_message(state: State) -> dict:
    return {"messages": [{"role": "user", "content": "hi"}]}
# Now: appends this message to the existing messages list

Any callable reducer(current, update) -> new_value works. Built-in patterns:

  • operator.add — concat lists or add numbers
  • lambda a, b: {**a, **b} — merge dicts (later wins on conflict)
  • Custom function — deduplicate, sort, apply business logic

See reducer function pattern.

MessagesState — the convenience wrapper

For chat-style graphs, LangGraph ships MessagesState which comes with the add_messages reducer already configured:

from langgraph.graph import MessagesState

class MyChatState(MessagesState):
    user_id: str
    turn_count: int

# messages field automatically has add_messages reducer

add_messages is smarter than plain add: it deduplicates messages by ID (if you pass a message with an existing ID, the new one replaces the old), preserves message order, and handles the different message types correctly. Use it unless you have a strong reason to hand-roll. See MessagesState reducer semantics.

Multiple nodes writing the same field

A common shape: two nodes run in parallel, both write to the same list field. Without a reducer, one overwrites the other — you lose data. With a reducer, updates concat:

class State(TypedDict):
    results: Annotated[list, add]

def search_a(state):
    return {"results": ["from A"]}

def search_b(state):
    return {"results": ["from B"]}

# With parallel edges, both run; results ends up as ["from A", "from B"]

This is the standard fan-out-fan-in pattern. See parallel edges fan-out.

Pydantic state — validation on every update

from pydantic import BaseModel, Field
from typing import Annotated
from operator import add

class State(BaseModel):
    query: str
    results: Annotated[list[str], add] = Field(default_factory=list)
    approved: bool = False

builder = StateGraph(State)

Pydantic validates every state update. If a node returns {"approved": "yes"} instead of a bool, Pydantic raises a validation error immediately instead of the bug surfacing three nodes later. Trade-off: adds a small per-update overhead. Worth it for production graphs; overkill for tight-loop internal workflows.

State update rules — the mental model

  1. Node function receives current state as input
  2. Node returns a partial dict (only fields it wants to update) or a Command object (chapter 5)
  3. LangGraph merges the returned dict into current state, one field at a time
  4. For each field, LangGraph checks if a reducer is declared; if yes, calls reducer(current, update); if no, overwrites
  5. Merged state becomes input to the next node

Understand these five steps and every "why did my state update disappear?" bug becomes obvious. See overwrite vs merge behavior.

The silent-overwrite trap

The most common LangGraph state bug: forgot the reducer on a list or dict field. Symptom: state field looks correct in single-node runs but goes missing values under parallel edges. Fix: add Annotated[list, add] (or the appropriate reducer). This bug is invisible in unit tests that only exercise one node at a time — it surfaces the moment your graph has parallel branches.

Chapter 04

Every LangGraph error category

LangGraph errors span ten categories, most tied to the framework's stateful surface (schemas, reducers, edges, checkpointers, interrupts) rather than to the underlying models. This chapter maps each category with links to specific error-fix pages.

1. Graph construction errors

Fires at: .compile() or first invocation.
Common causes: Missing entry point (no edge from START); node referenced in edge but never added; .compile() called before all nodes added; recursion limit exceeded during execution.

LangGraph validates the graph structure at compile time. If START has no outgoing edge, compile fails. If an edge references a node name that doesn't exist, compile fails. At runtime, if the graph loops without hitting END, GraphRecursionError fires after the default 25 iterations. See missing entry point and recursion limit.

2. State schema errors

Fires at: State update merge, or Pydantic validation.
Common causes: Node returns a field not declared in the schema (silently ignored on TypedDict, validation error on Pydantic); return value shape doesn't match schema; reducer function signature wrong.

TypedDict schemas fail silently — a typo like "messsages" vs "messages" just adds an untracked key. Pydantic catches this with a validation error. Chapter 3 covers reducers. See TypedDict vs Pydantic.

3. Reducer & state-merge errors

Fires at: Runtime, when multiple nodes update the same field.
Common causes: Missing reducer on a list/dict field (parallel updates overwrite); reducer function raising on unexpected input types; using add_messages without importing from the right module.

Silent-overwrite is the #1 state bug. If a field looks correct in single-path runs but goes missing values under parallel edges, you forgot a reducer. See overwrite vs merge.

4. Node & Command errors

Fires at: Node execution or edge routing.
Common causes: Node returns wrong type (must be dict, Command, or None); Command(goto=...) references a non-existent node; mixing Command update with return dict (Command wins, dict ignored — sometimes surprising).

Chapter 5 covers nodes and the Command pattern. See Command goto node not found.

5. Edge & conditional routing errors

Fires at: Compile or runtime routing.
Common causes: add_conditional_edges router returns a name not in the mapping; router returns a list of names but graph expects a single destination; END never reached; parallel edges create race conditions on non-reduced state fields.

Chapter 6 covers routing. See conditional edges mapping.

6. Checkpointer errors

Fires at: First invocation with checkpointer configured, or during resume.
Common causes: MemorySaver used in production (data lost on restart); Postgres checkpointer schema not migrated; Redis connection pool exhausted under load; thread_id missing from config; using checkpointer on a graph without threading through configurable.

MemorySaver in production is a common footgun — works in dev, silently loses state when the process restarts. Chapter 7 walks each production checkpointer. See MemorySaver not durable.

7. Interrupt & HITL errors

Fires at: interrupt() call or resume.
Common causes: Resuming without a Command object; resume payload doesn't match the interrupt shape; interrupt in subgraph loses prior task outputs (known bug pattern); using static breakpoints instead of dynamic interrupt() when the logic needs conditionality.

Chapter 8 walks HITL patterns. Subgraph + interrupt combinations have known edge cases — see the interrupt in subgraph error page.

Related pages: Resume payload · Interrupt in subgraph bug

8. Subgraph errors

Fires at: Subgraph invocation from parent, or state passing.
Common causes: Subgraph state schema doesn't match what parent expects; checkpointer=True on subgraph causes skip (known bug in some versions); supervisor + specialist handoff creates infinite loop; state fields with different reducers in parent vs subgraph.

Subgraphs are powerful and finicky. Chapter 9 covers patterns and known pitfalls. See subgraph checkpointer=True bug.

9. Streaming errors

Fires at: graph.stream() or graph.astream_events().
Common causes: Wrong stream_mode for the workload (values vs updates vs messages); subgraphs=True forgotten so nested events don't surface; astream_events v2 schema differences from v1; consuming stream in a context that ends before it completes.

Chapter 10 covers streaming. See stream_mode variants.

10. Deployment & platform errors

Fires at: langgraph-cli commands or LangGraph Platform API.
Common causes: langgraph.json config invalid; graph module path wrong; environment variables missing at build time; thread_id not found on Platform (thread expired or wrong tenant); hot reload during dev loses state.

Chapter 11 covers Platform deployment. See langgraph.json config errors.

Chapter 05

Nodes & the Command pattern

A node is a function that takes state and returns either a state update dict, a Command object (which can update state AND control routing in one), or None. Understanding when to return which is the difference between a graph that reads naturally and one that fights the framework.

The node function signature

from typing import TypedDict

class State(TypedDict):
    query: str
    result: str

def process(state: State) -> dict:
    return {"result": f"processed: {state['query']}"}

Minimum contract: takes a state, returns a partial dict of updates. Sync or async both work (LangGraph handles the dispatch). Type annotations aren't required at runtime but make the graph easier to reason about.

Nodes with config

from langchain_core.runnables import RunnableConfig

def process(state: State, config: RunnableConfig) -> dict:
    user_id = config["configurable"].get("user_id", "anon")
    return {"result": f"processed for {user_id}"}

Adding a second parameter of type RunnableConfig gives the node access to runtime config — thread_id, user-supplied configurable values, tags, metadata. Useful for per-request context you don't want to bloat the state schema with.

Nodes returning None

A node returning None makes no state update. Useful for side-effect nodes (logging, sending notifications, writing to external systems):

def log_progress(state: State) -> None:
    logger.info("Query processed", extra={"query": state["query"]})
    # No return, no state change

The Command pattern

Command lets a node control both state and routing in one return:

from langgraph.types import Command
from typing import Literal

def route_by_intent(state: State) -> Command[Literal["support", "sales", "billing"]]:
    intent = classify(state["query"])

    return Command(
        goto=intent,           # ← route to the named node
        update={"routed_to": intent},   # ← also update state
    )

Two things happen in one return: state update, plus explicit routing to a named node. The Literal[...] in the return type helps type checkers verify the routing targets exist.

Command shines when routing decisions are computed inside the node from data the node just fetched — otherwise you'd fetch the data in the node, return state, then re-fetch or reason about the same data in a separate conditional-edge router.

Command vs conditional edges — when to use which

Situation Best fit
Routing depends on data the node computed Command(goto=...)
Routing depends on state that's already set before this node add_conditional_edges
Many nodes route to the same next-node based on the same field add_conditional_edges (deduplicates the routing logic)
Node needs to both update state AND route based on the new state Command(goto=..., update=...)

See Command update vs return dict.

Command with subgraphs

Command can also route to a subgraph's parent-level nodes using graph=Command.PARENT:

def specialist_node(state):
    return Command(
        goto="supervisor",           # node name in parent graph
        graph=Command.PARENT,        # ← route in parent, not this subgraph
        update={"specialist_result": "..."},
    )

Useful for the supervisor pattern: specialist agents finish their work and hand back to the supervisor node in the parent graph.

Node execution guarantees

  • At-least-once — with a checkpointer, a node may retry on failure. Design nodes to be idempotent where possible.
  • Ordering — LangGraph respects declared edges. Parallel edges (fan-out) don't guarantee execution order within the parallel set.
  • State visibility — a node sees state as of the checkpoint before it started. Updates from parallel siblings aren't visible until the fan-in.
  • Failure — an unhandled exception in a node fails the invocation. Wrap external calls in try/except; return state that lets the router send the graph to an error-handling node.
Idempotency matters more than in linear code

Any node that hits an external system (payment, email, calendar API) should be idempotent — with checkpointing, that node may run twice if the graph resumes after a crash between the node completing and the checkpoint being persisted. Use idempotency keys derived from state fields, not per-invocation values.

Chapter 06

Edges & conditional routing

Edges connect nodes. Fixed edges are simple — add_edge(from, to). Conditional edges are where LangGraph's real power lives: add_conditional_edges(from, router_fn, mapping) routes dynamically based on state. This chapter covers both, plus parallel edges (fan-out), the END node, and common routing pitfalls.

Fixed edges

builder.add_edge(START, "fetch")
builder.add_edge("fetch", "process")
builder.add_edge("process", END)

A fixed edge always fires. If node A has one outgoing fixed edge to B, after A completes, B runs. Simple, deterministic, no runtime decision.

Conditional edges

from typing import Literal

def route_by_intent(state) -> Literal["support", "sales", "billing"]:
    return classify(state["query"])

builder.add_conditional_edges(
    "classifier",
    route_by_intent,
    {
        "support": "support_agent",
        "sales": "sales_agent",
        "billing": "billing_agent",
    },
)

The router function receives state, returns a string. The mapping translates that string to a node name. If the router returns a string not in the mapping, LangGraph raises an error. See conditional edges mapping.

Shortcut: if the router returns the node name directly, you can skip the mapping:

def route(state) -> str:
    if state["needs_support"]:
        return "support_agent"
    return END

builder.add_conditional_edges("classifier", route)

Parallel edges (fan-out)

A node with multiple outgoing edges fans out — LangGraph runs the downstream nodes in parallel:

builder.add_edge("orchestrator", "search_web")
builder.add_edge("orchestrator", "search_docs")
builder.add_edge("orchestrator", "search_db")

# All three "search_*" nodes run in parallel after orchestrator.
# Then they fan back in to whatever node they connect to next.

Critical: any state field the parallel nodes write to needs a reducer, or you lose data. If search_web, search_docs, and search_db all return {"results": [...]} and the results field has no reducer, one of them wins and the others' results are lost.

Fix: declare the reducer:

from operator import add
from typing import Annotated, TypedDict

class State(TypedDict):
    results: Annotated[list, add]   # ← reducer merges parallel updates

# Now all three searches' results end up in the merged list.

See parallel edges fan-out race.

Conditional edges to multiple destinations (Send API)

Sometimes you want a router to dispatch multiple nodes with different inputs — the map-reduce pattern. Use the Send API:

from langgraph.types import Send

def fan_out_topics(state):
    return [
        Send("research_topic", {"topic": t}) for t in state["topics"]
    ]

builder.add_conditional_edges("planner", fan_out_topics, ["research_topic"])

Send lets you invoke the same node multiple times in parallel with different inputs — useful for "for each topic, research it" workflows. Results fan back in via the state's reducer.

The END node

END is a special reserved node that terminates the graph. Any node can route to END:

from langgraph.graph import END

def route(state) -> str:
    if state["done"]:
        return END
    return "loop_body"

builder.add_conditional_edges("loop_body", route)

Common bug: routing logic never returns END, graph loops until GraphRecursionError fires at the default recursion limit of 25. Fix: verify termination conditions in every conditional router. See END never reached.

Adjusting the recursion limit

graph.invoke(
    input,
    config={"configurable": {"thread_id": "t-1"}, "recursion_limit": 100},
)

Default is 25. For legitimate multi-step agent loops that exceed 25, raise it. If you find yourself raising it above 100, reconsider whether the workflow is actually converging.

Routing pitfalls

  • Router returns None — LangGraph treats as an error. Return END or a valid node name always.
  • Router raises an exception — fails the invocation. Wrap risky routing logic and route to an error-handling node instead.
  • Cycles without a termination condition — GraphRecursionError. Every loop needs a guaranteed exit path.
  • Race conditions on shared state — parallel nodes writing the same field without a reducer.
  • Send-based fan-out reducer mismatch — if you Send to N invocations of a node, each returning {"results": [x]}, the reducer must handle N concurrent updates.
Chapter 07

Checkpointers & persistence

Checkpointers turn LangGraph from a fancy DAG runner into a durable, resumable workflow engine. Every checkpointer serializes the full graph state after every node — enabling failure recovery, human-in-the-loop pause, time-travel debugging. Which checkpointer you pick determines whether "resume after crash" is a five-second recovery or a data-loss incident.

The four production checkpointers

Checkpointer Package Use case Durability
MemorySaver langgraph (built-in) Dev, tests, quick prototypes None — process restart loses everything
SqliteSaver langgraph-checkpoint-sqlite Single-instance deployments, local persistence File-level; not concurrent-safe across processes
PostgresSaver langgraph-checkpoint-postgres Most production deployments Full transactional; scales to many concurrent workers
RedisSaver langgraph-checkpoint-redis Very high-throughput; can accept memory-level durability Depends on Redis persistence config (AOF vs RDB vs none)

MemorySaver — dev only

from langgraph.checkpoint.memory import MemorySaver

graph = builder.compile(checkpointer=MemorySaver())

Zero setup, in-process. Perfect for tests and local prototypes. In production: state exists only in the current process's memory. Worker restart loses state; horizontal scaling means each replica has its own state.

The most common production incident with MemorySaver: everything works in dev, works in staging on a single worker, silently loses state in production the first time a worker restarts. See MemorySaver not durable.

PostgresSaver — the production default

from langgraph.checkpoint.postgres import PostgresSaver
from psycopg_pool import ConnectionPool

pool = ConnectionPool(conninfo=os.environ["DATABASE_URL"], max_size=20)
checkpointer = PostgresSaver(pool)

# One-time schema setup
checkpointer.setup()

graph = builder.compile(checkpointer=checkpointer)

Postgres is the recommended production checkpointer. Full transactional guarantees, mature operational tooling, scales to many concurrent workers. First-time setup runs migrations to create langgraph_checkpoints and related tables.

Common setup errors:

  • Schema not migrated — call checkpointer.setup() once on deploy, or bundle the migration into your app's init
  • Connection pool too small — checkpointer writes on every node, so pool must accommodate concurrent node execution
  • psycopg version mismatch — langgraph-checkpoint-postgres 2.0+ uses psycopg 3, not psycopg2

See Postgres schema migration.

RedisSaver — high throughput

from langgraph.checkpoint.redis import RedisSaver

checkpointer = RedisSaver.from_conn_string(os.environ["REDIS_URL"])

graph = builder.compile(checkpointer=checkpointer)

Redis-backed. Faster than Postgres per write, but durability depends on Redis config — default RDB snapshots can lose recent state on Redis crash. Enable AOF (append-only file) for stronger durability at the cost of write latency. Suitable for high-throughput workloads where an occasional lost thread is acceptable.

Connection pool sizing matters — checkpointing writes on every node execution, so a pool too small under high concurrency exhausts and blocks nodes. See Redis pool exhaustion.

SqliteSaver — single-instance

from langgraph.checkpoint.sqlite import SqliteSaver

checkpointer = SqliteSaver.from_conn_string("checkpoints.db")
graph = builder.compile(checkpointer=checkpointer)

File-based. Persists across process restarts (unlike MemorySaver). Not safe for concurrent writes across processes — SQLite's file-level locking becomes a bottleneck. Fine for single-instance deployments, local desktop apps, or CI test suites needing persistence.

The thread_id requirement

Every checkpointed invocation needs a thread_id in the config:

config = {"configurable": {"thread_id": "user-42-conv-1"}}
graph.invoke(input, config)

Miss the thread_id and the checkpointer raises an error. In production, derive thread_id from a stable identifier (user_id + conversation_id) so subsequent invocations resume the right thread. See thread_id missing.

Checkpoint granularity trade-off

LangGraph checkpoints after every node execution by default. For most workflows this is right — decision boundaries are cheap enough that persistence is worth it. For high-frequency workflows (token-level streaming steps, tight retry loops), the write overhead adds up.

Pattern for selective checkpointing: compile an inner subgraph without a checkpointer for the high-frequency interior work, compile the outer graph with a durable checkpointer for the decision boundaries that matter:

# Inner subgraph — no checkpointer, cheap to re-run on failure
inner = inner_builder.compile()

def call_inner(state):
    result = inner.invoke({"query": state["query"]})
    return {"processed": result["output"]}

# Outer graph — durable checkpointer at meaningful boundaries only
outer_builder.add_node("inner_work", call_inner)
graph = outer_builder.compile(checkpointer=PostgresSaver(pool))

Trade-off: coarser checkpointing means more re-execution after a failure (replay from the last outer node boundary, not the last inner node). Finer means more write overhead and larger checkpoint tables. Most production workflows: checkpoint at each major phase boundary (search → synthesize → review), not every micro-step.

Retention & cleanup

Left alone, the checkpoints table grows unbounded. Every completed thread's history sits there forever. Implement a retention policy:

  • Identify threads in terminal state (final node reached, no next nodes)
  • Archive or purge on a retention schedule (30 days, 90 days, whatever your compliance allows)
  • Run as a background job, not synchronously in your app

Without this, the langgraph_checkpoints table becomes an unqueried archive of every agent run you've ever done, and Postgres bloats.

Chapter 08

Interrupts & human-in-the-loop

Human-in-the-loop became table stakes for enterprise LangGraph deployments in 2026. Any agent that takes irreversible actions — wires money, sends emails, modifies infrastructure — needs an approval pause. LangGraph's interrupt() primitive handles this: pause the graph mid-execution, persist state via the checkpointer, wait indefinitely for an external resume signal via Command. This chapter covers dynamic interrupts, static breakpoints, and the resume pattern.

The dynamic interrupt pattern

from langgraph.types import Command, interrupt
from typing import Literal, Optional, TypedDict

class ApprovalState(TypedDict):
    action_details: str
    status: Optional[Literal["pending", "approved", "rejected"]]

def approval_node(state: ApprovalState) -> Command[Literal["proceed", "cancel"]]:
    decision = interrupt({
        "question": "Approve this action?",
        "details": state["action_details"],
    })

    return Command(goto="proceed" if decision else "cancel")

def proceed_node(state):
    return {"status": "approved"}

def cancel_node(state):
    return {"status": "rejected"}

builder = StateGraph(ApprovalState)
builder.add_node("approval", approval_node)
builder.add_node("proceed", proceed_node)
builder.add_node("cancel", cancel_node)
builder.add_edge(START, "approval")
builder.add_edge("proceed", END)
builder.add_edge("cancel", END)

graph = builder.compile(checkpointer=MemorySaver())

When interrupt() is called, LangGraph saves state, returns the interrupt payload to the caller, and pauses. The graph doesn't resume until you re-invoke with a Command(resume=...).

The resume pattern

# First invocation — hits interrupt, pauses
config = {"configurable": {"thread_id": "approval-1"}}
result = graph.invoke({"action_details": "Delete user account"}, config)

# result["__interrupt__"] contains the interrupt payload
# Show the payload in a UI, get human decision

# Second invocation — resume with the decision
graph.invoke(Command(resume=True), config)   # or resume=False

Two important things:

  1. The resume value becomes the return of interrupt() inside the node
  2. Same thread_id — the checkpointer picks up where it left off

See interrupt Command resume payload.

Dynamic vs static breakpoints

Two ways to pause the graph:

Method How When to use
Dynamic (interrupt()) Call interrupt(payload) anywhere inside a node Conditional pause; pause on data-dependent trigger; production HITL
Static (interrupt_before / interrupt_after) Configure at compile: interrupt_before=["node_name"] Debugging; always-pause-here workflows; simple approval gates

Dynamic interrupts win for production. Static breakpoints are useful during development to inspect state at a node boundary. See static vs dynamic.

The subgraph interrupt bug — a known trap

When interrupt() is called inside a subgraph invoked from a parent, and the parent uses the @entrypoint/@task functional API with a checkpointer, prior task outputs may not be reused on resume — every task in the subgraph re-runs from scratch. This is a known issue in some LangGraph versions.

Workarounds:

  • Compile the subgraph with its own checkpointer
  • Use StateGraph in the parent instead of the functional API when interrupt+subgraph is involved
  • Pin LangGraph to a version where the bug is resolved for your workflow
  • Track the issue on the LangGraph repo

See interrupt in subgraph task reuse.

Time-travel — replay from a past checkpoint

Checkpointing enables time travel: fetch the state history for a thread, pick a past checkpoint, fork from there:

history = list(graph.get_state_history(config))

# history is newest-first; pick a specific checkpoint
past_checkpoint = history[3]
past_config = past_checkpoint.config   # includes the specific checkpoint_id

# Optionally modify state before resuming
graph.update_state(past_config, {"status": "reapproved"})

# Resume from the modified past state
graph.invoke(None, past_config)

Powerful for debugging: reproduce a production failure by loading the failing thread's history, forking at the failing checkpoint, and replaying with a fix. Also useful for what-if analysis. See time-travel fork.

HITL patterns in the wild

  • Approval before irreversible action — wire money, delete records, publish content
  • Human clarification — agent stuck on ambiguity, ask the user to disambiguate
  • Fact verification — agent's answer needs human check before sending to a customer
  • Escalation — agent can't handle case, pause and route to human queue
  • Long-running approval windows — legal review taking days; graph pauses, resumes on approval
Interrupts and durable checkpointers are a package deal

interrupt() without a durable checkpointer means the pause survives only as long as the process. If your worker restarts while a thread is paused waiting for approval, the state is lost. In production, always pair interrupts with a durable checkpointer (Postgres or Redis with AOF). MemorySaver + interrupts is a dev-only combination.

Chapter 09

Subgraphs & multi-actor patterns

Subgraphs let you compose a StateGraph as a node inside another StateGraph. Enables three important patterns: reuse of common workflows, encapsulation of specialist agents behind a supervisor, and modular graph composition where teams own separate subgraphs. It also introduces the trickiest bugs in LangGraph — state schema mismatches, checkpointer collisions, and handoff loops between supervisor and specialists.

Adding a subgraph as a node

# Subgraph — a normal StateGraph
sub_builder = StateGraph(SubState)
sub_builder.add_node("step1", step1_fn)
sub_builder.add_node("step2", step2_fn)
sub_builder.add_edge(START, "step1")
sub_builder.add_edge("step1", "step2")
sub_builder.add_edge("step2", END)
subgraph = sub_builder.compile()

# Parent graph — subgraph is just another node
parent_builder = StateGraph(ParentState)
parent_builder.add_node("pre", pre_fn)
parent_builder.add_node("sub", subgraph)   # ← subgraph as node
parent_builder.add_node("post", post_fn)
parent_builder.add_edge(START, "pre")
parent_builder.add_edge("pre", "sub")
parent_builder.add_edge("sub", "post")
parent_builder.add_edge("post", END)
graph = parent_builder.compile(checkpointer=PostgresSaver(pool))

From the parent's perspective, the subgraph is just another node — takes state, returns state updates. From inside the subgraph, it runs its own graph with its own state schema.

State schema across the boundary

Two schemas involved:

  • Parent state schema — what fields the parent graph tracks
  • Subgraph state schema — what fields the subgraph tracks

When the parent invokes the subgraph node, LangGraph passes shared fields from parent state to subgraph state, runs the subgraph, and merges subgraph output back into parent state. Fields shared by name are automatically threaded. Fields unique to the subgraph stay local.

Common bug: parent has a field named messages with an add_messages reducer; subgraph has a field named messages with a plain overwrite. On subgraph invocation, the parent's messages get overwritten by whatever the subgraph puts in messages. Fix: use the same reducer in both, or rename one to avoid the collision. See subgraph state schema mismatch.

The checkpointer=True subgraph bug

Setting checkpointer=True on a subgraph (intending to inherit the parent's checkpointer) has caused the subgraph to be skipped entirely in some LangGraph versions. The parent thinks the subgraph completed, downstream nodes run with unchanged state, execution appears successful but wrong.

Workarounds:

  • Explicitly pass the same checkpointer instance to the subgraph's .compile() call — don't rely on inheritance
  • Compile subgraphs without a checkpointer and let the parent's checkpointer capture parent-level state only
  • Pin to a version where the bug is resolved for your workflow

See subgraph checkpointer=True skip.

The supervisor pattern

The most common multi-actor pattern: a supervisor agent decides which specialist to invoke, specialists do their work, hand back to supervisor, supervisor either delegates again or finishes.

from langgraph.types import Command
from typing import Literal

def supervisor(state):
    # LLM decides next specialist based on state
    next_agent = call_llm_router(state)

    if next_agent == "done":
        return Command(goto=END)

    return Command(goto=next_agent)

def researcher(state):
    result = do_research(state["query"])
    return Command(
        goto="supervisor",
        update={"research_results": result},
    )

def writer(state):
    draft = write_draft(state["research_results"])
    return Command(
        goto="supervisor",
        update={"draft": draft},
    )

builder = StateGraph(SupervisorState)
builder.add_node("supervisor", supervisor)
builder.add_node("researcher", researcher)
builder.add_node("writer", writer)
builder.add_edge(START, "supervisor")

graph = builder.compile(checkpointer=PostgresSaver(pool))

Each specialist returns Command back to supervisor. Supervisor decides next step from updated state. Loop continues until supervisor routes to END.

The handoff loop problem

Common failure: supervisor routes to specialist, specialist hands back, supervisor sees no meaningful state change, routes to same specialist again — infinite loop until GraphRecursionError.

Prevention:

  • Every specialist must update state in a way the supervisor can observe (a "done" flag, a "results" field the supervisor checks)
  • Supervisor's routing logic must consider what's already been done, not just what's next
  • Set an explicit turn counter in state, exit after N turns as a safety net
  • Structured output on the supervisor's router LLM call, restricting its choices

See supervisor handoff loop.

When to use subgraphs vs a big flat graph

Situation Best fit
Reusable workflow used from multiple parent graphs Subgraph
Team owns a specific portion of the workflow independently Subgraph (owner-encapsulation)
Specialist agent with its own tool set and state Subgraph
Selective checkpointing (fine inner, coarse outer) Subgraph without checkpointer inside outer graph with checkpointer
Straightforward workflow with 10-20 nodes Flat StateGraph — subgraphs add complexity for no gain
Debugging is your #1 priority Flat — subgraphs add layers to trace through
Chapter 10

Streaming, events & observability

LangGraph exposes graph execution as streams — state updates, node events, LLM tokens, tool calls. Five stream modes cover different granularities. Combined with LangSmith tracing, you get a full observability picture of every graph invocation. This chapter covers each stream mode, the astream_events v2 schema, and the subgraph-events pattern.

The five stream modes

stream_mode Yields Use case
"values" Full state after each node completes Progress UI showing current full state
"updates" Just the state delta from each node Efficient progress tracking; smaller payloads
"messages" Message-level streaming (for chat graphs) Token-by-token chat UI
"debug" Detailed execution events (start, end, error per node) Debugging graph execution flow
"custom" User-emitted events via get_stream_writer() Progress signals from inside nodes

Modes can be combined via a tuple: stream_mode=("updates", "messages") yields tagged events from both streams. See stream_mode variants.

values mode

config = {"configurable": {"thread_id": "t-1"}}

for state in graph.stream({"query": "hello"}, config, stream_mode="values"):
    print(f"State so far: {state}")
    # Yields full state after each node

Simple to consume — each yield is the whole state. Downside: state can get large (accumulating message histories), so payloads grow over the invocation.

updates mode

for update in graph.stream({"query": "hello"}, config, stream_mode="updates"):
    # update is {node_name: node_output_dict}
    for node_name, output in update.items():
        print(f"{node_name} produced: {output}")

More efficient — each yield contains just what changed and which node changed it. Better for progress bars and per-node UI updates.

messages mode — token-level chat streaming

for message_chunk, metadata in graph.stream(
    {"messages": [("user", "Hi")]},
    config,
    stream_mode="messages",
):
    # message_chunk is an AIMessageChunk from the model
    if message_chunk.content:
        for block in message_chunk.content:
            if block["type"] == "text":
                print(block["text"], end="", flush=True)

Perfect for chat UIs — yields individual token chunks as the model generates. Metadata tells you which node emitted the chunk (useful when multiple nodes call the model).

Streaming from subgraphs

By default, graph.stream() only emits events from the top-level graph. Subgraph internal events are hidden. To see them:

for namespace, event in graph.stream(
    input,
    config,
    stream_mode="updates",
    subgraphs=True,   # ← surface subgraph events
):
    if namespace:
        print(f"[subgraph {namespace}] {event}")
    else:
        print(f"[parent] {event}")

Events from subgraphs come with a namespace tuple identifying which subgraph emitted them. Without subgraphs=True, you're blind to what happens inside subgraph nodes. See subgraphs stream_mode missing events.

astream_events v2

For fine-grained event streams — every Runnable start/end, every LLM token, every tool call — use astream_events:

async for event in graph.astream_events(input, config, version="v2"):
    kind = event["event"]
    if kind == "on_chat_model_stream":
        chunk = event["data"]["chunk"]
        # token-level LLM output
    elif kind == "on_tool_start":
        # tool being invoked
        print(f"Tool: {event['name']}, input: {event['data']['input']}")
    elif kind == "on_chain_end":
        # a Runnable completed
        pass

v2 schema replaced v1 in mid-2025 with more consistent event shapes and better subgraph support. If you have code parsing events from an older LangChain version, event shapes changed. See astream_events v2 with LangGraph.

Custom events from inside nodes

from langgraph.config import get_stream_writer

def long_running_node(state):
    writer = get_stream_writer()

    for i, item in enumerate(state["items"]):
        writer({"progress": i, "total": len(state["items"])})
        process(item)

    return {"done": True}

# Consumer sees custom events in stream
for event in graph.stream(input, config, stream_mode="custom"):
    print(event)   # {"progress": 5, "total": 100}

Useful for surfacing progress inside long-running nodes without polluting state.

LangSmith tracing for LangGraph

Set the standard LangSmith env vars and every graph invocation traces automatically:

export LANGSMITH_API_KEY="lsv2_..."
export LANGSMITH_TRACING="true"
export LANGSMITH_PROJECT="my-graph-prod"

Traces show the graph structure, each node's inputs and outputs, LLM calls made inside nodes, tool calls, latency per node. For debugging why a graph took a particular path, LangSmith is essential — a text log doesn't show the branching decisions the graph made.

Trace tags per invocation

Attach tags and metadata via config for filtering in LangSmith:

config = {
    "configurable": {"thread_id": "t-42"},
    "tags": ["prod", "premium-tier", "supervisor-flow"],
    "metadata": {"user_id": "u-42", "feature": "research-agent"},
}
graph.invoke(input, config)
Chapter 11

LangGraph Platform & deployment

LangGraph Platform is LangChain's hosted deployment layer for LangGraph graphs — persistent threads, assistant management, cron jobs, webhooks, and an SDK for external clients. You can also run the same runtime self-hosted via langgraph-cli. This chapter covers the deployment surface, the langgraph.json config file, and common setup errors.

The deployment surface

LangGraph Platform gives you an HTTP API around your compiled graphs:

  • Threads — persistent conversations, one thread per user/session
  • Runs — invocations against a thread, sync or streaming
  • Assistants — versioned graph configurations (different prompts/models per assistant, same underlying graph)
  • Cron jobs — scheduled invocations of a graph
  • Webhooks — HTTP callbacks on run completion
  • Store — cross-thread persistent memory (user preferences, long-term facts)

langgraph.json — the config file

{
  "dependencies": ["."],
  "graphs": {
    "research_agent": "./my_app/graphs.py:research_graph",
    "support_agent": "./my_app/graphs.py:support_graph"
  },
  "env": ".env",
  "python_version": "3.11"
}

Sits at the root of your project. Declares:

  • dependencies — Python packages (path or requirements files) to install
  • graphs — mapping of graph name to path:variable (the compiled graph exported from a module)
  • env — env file to load at build time
  • python_version — Python runtime version

Errors in langgraph.json surface on langgraph build or langgraph dev. Common mistakes: wrong path syntax (must be path:variable, not just path); graph variable not exported from the module; missing dependencies file. See langgraph.json config errors.

Local dev with langgraph-cli

pip install langgraph-cli

# Start local dev server
langgraph dev

# Opens at http://localhost:2024
# LangGraph Studio UI available for visual debugging

langgraph dev spins up the runtime locally with hot reload. State persists in local Postgres (auto-provisioned). Good for iteration but the hot reload can lose in-flight threads on graph changes — a known trade-off. See hot reload state loss.

Deploying to LangGraph Platform (hosted)

  1. langgraph build — builds a Docker image
  2. langgraph deploy — pushes to LangGraph Platform
  3. Platform provisions runtime, Postgres, Redis
  4. Get an API endpoint back for your deployed graphs

Alternative: self-hosted via langgraph up to build and run the same runtime in your own infrastructure. Requires your own Postgres + Redis.

The Python SDK for calling deployed graphs

from langgraph_sdk import get_client

client = get_client(url="https://my-deployment.langgraph.app", api_key="...")

# Create a thread
thread = await client.threads.create()

# Stream a run
async for chunk in client.runs.stream(
    thread["thread_id"],
    "research_agent",   # graph name from langgraph.json
    input={"query": "..."},
):
    print(chunk)

Same LangGraph runtime behavior; client-server split. Thread persistence, streaming, interrupts all work over the wire.

Common Platform errors

  • Thread not found (404) — wrong thread_id or thread expired per your retention policy. See thread not found.
  • Graph not found — graph name doesn't match the key in langgraph.json
  • Build failure — langgraph build can't resolve dependencies, wrong Python version, or graph module import fails
  • Cold start latency — first request after idle period pays cold-start cost; consider keeping the deployment warm for latency-sensitive workloads

When to use Platform vs self-host

Situation Best fit
Small team, prototype-to-production, no infra team LangGraph Platform (hosted)
Enterprise with existing K8s / infra pipelines Self-host via langgraph up
Strict data residency (must run in specific region/VPC) Self-host
Need fine control over scaling / resource limits Self-host
Cron jobs + webhooks + assistants management out of the box LangGraph Platform
Chapter 12

Production checklist

Twenty items to verify before your LangGraph deployment goes to production. Each corresponds to at least one incident from real 2026 deployments — with attention to the LangGraph-specific issues (checkpointer durability, state reducers, interrupt reliability, subgraph edge cases) that don't apply elsewhere.

State & graph structure

  1. State schema uses reducers on every list/dict field that any parallel or fan-in path writes to
  2. State schema uses Pydantic (not TypedDict) for graphs where multiple engineers contribute nodes
  3. Every conditional edge router has a guaranteed termination path to END
  4. Recursion limit set explicitly (default 25) — raised only if the workflow legitimately needs more

Checkpointer durability

  1. Not using MemorySaver in production — swapped for PostgresSaver, RedisSaver, or SqliteSaver based on scale
  2. PostgresSaver schema migrated on first deploy (checkpointer.setup())
  3. Connection pool sized for concurrent node execution (writes fire on every node)
  4. Retention policy in place for terminated threads — background job archives or purges to prevent unbounded table growth

Interrupts & HITL

  1. All interrupt() usage paired with a durable checkpointer, not MemorySaver
  2. Resume payload shape documented — external systems know what to pass as Command(resume=...)
  3. Timeout logic for interrupts that wait indefinitely — external monitor cancels stuck threads after N hours/days
  4. Time-travel tested — can you actually replay from a past checkpoint when needed?

Subgraphs & multi-actor

  1. Subgraph state schemas verified compatible with parent (matching reducers on shared fields)
  2. Not using checkpointer=True on subgraphs — passing an explicit checkpointer instance instead
  3. Supervisor patterns have a turn counter or explicit termination condition — no reliance on the LLM to always route to END

Observability & operations

  1. LangSmith tracing configured with project name, tags, and metadata for filtering
  2. Streaming subgraphs=True where subgraph internals need surfacing to UI
  3. Sensitive fields redacted before checkpoints or traces persist
  4. Node functions are idempotent (or use idempotency keys) for external-facing operations — checkpointing means potential re-execution
  5. Deployment (Platform or self-host) has cold-start warmup for latency-sensitive workflows
The three LangGraph-specific traps most teams hit

Item #1 (reducers) is the #1 silent-data-loss bug — everything works in single-path testing, fails under parallel edges. Item #5 (MemorySaver in prod) is the #1 durability incident — works until the first worker restart. Item #15 (supervisor termination) is the #1 cost incident — an LLM-driven router loops until GraphRecursionError, burning tokens on every iteration.

Chapter 13

Frequently asked questions

When should I use LangGraph vs LangChain's create_agent?

Use create_agent for simple model+tools loops, even with middleware. Reach for LangGraph directly when your workflow has: branching or conditional routing, human-in-the-loop approval, long-running workflows that pause for hours/days, multi-actor patterns (supervisor + specialists), parallel fan-out with fan-in aggregation. If your workflow is a diagram with more than one path through it, LangGraph is the right tool.

TypedDict or Pydantic for state schema?

TypedDict for prototypes and single-owner graphs. Pydantic for production where state crosses team boundaries, validation catches bugs early, or you need serialization. Both work; pick one per graph. Pydantic adds a small overhead per update but the runtime validation pays for itself the first time a node returns a malformed field.

Why is my state field getting overwritten instead of merged?

You forgot the reducer. Without Annotated[list, add] (or another reducer), returning a field means overwrite. Parallel nodes writing the same field means one wins, others lost. Fix: declare the reducer in the state schema. This bug is invisible in single-path testing — it only shows under parallel edges.

Can I use MemorySaver in production?

No. MemorySaver stores state in process memory only. Worker restart loses all state; horizontal scaling means each replica has its own state. Use PostgresSaver for most production (transactional, mature tooling) or RedisSaver for high-throughput workloads where you can accept memory-tier durability trade-offs. SqliteSaver works for single-instance deployments.

What's the difference between Command and returning a dict from a node?

Returning a dict updates state. Command does that plus explicit routing via goto=. Use dict when the next node is determined by the graph edges. Use Command when the routing depends on data the node just computed — cleaner than "return dict, then add a conditional edge that re-reads that data to route."

Why does my graph fail with GraphRecursionError?

The graph looped without hitting END for more than the recursion limit (default 25). Two causes: (1) legit long workflow that needs a higher limit — raise via config["recursion_limit"] = 100. (2) Router logic never returns END — bug in your conditional edge or supervisor. Debug by streaming stream_mode="updates" to see which node keeps firing.

Are interrupt() calls durable across process restarts?

Only if paired with a durable checkpointer. MemorySaver + interrupt = state lost on restart. PostgresSaver + interrupt = state survives, resume works after any restart. For enterprise HITL workflows (approvals taking hours/days), always use a durable checkpointer.

How do I resume a graph after an interrupt?

Re-invoke with Command(resume=<value>) and the same thread_id. The resume value becomes the return of interrupt() inside the node. LangGraph reads the checkpoint, resumes from where it paused, and continues execution.

Why does my subgraph get skipped when I set checkpointer=True?

Known bug pattern in some LangGraph versions. Workaround: pass an explicit checkpointer instance to the subgraph's .compile(), don't rely on inheritance. Or compile the subgraph without a checkpointer if selective (outer-only) checkpointing works for your use case.

My supervisor + specialist agents loop infinitely. What went wrong?

Specialists aren't updating state in a way the supervisor can observe as progress. Supervisor sees the same-looking state, routes to the same specialist, loop. Fix: (1) specialists must update state observably (a "done" flag, a "results" field), (2) supervisor's routing considers what's already done, (3) add a turn counter as a safety net with an explicit exit condition.

What's the difference between stream_mode="values" and "updates"?

"values" yields the full state after each node — easy to consume, but state grows as it accumulates. "updates" yields just the delta (which node changed what) — more efficient, better for UI progress. Use "messages" for token-level chat streaming, "custom" for progress signals from inside long-running nodes.

How do I see events from inside subgraphs?

Add subgraphs=True to graph.stream(). Without it, subgraph internal events are hidden — you only see the subgraph node as a single event from the parent's perspective. With it, you get a namespace tuple identifying which subgraph each event came from.

Should I use LangGraph Platform or self-host?

Platform for small teams without infra staff, prototype-to-production velocity, wanting cron/webhooks/assistants out of the box. Self-host (via langgraph up) when you have K8s pipelines, strict data residency requirements, or need fine control over scaling. Same runtime in both.

Do I need LangSmith for LangGraph observability?

Not required, but strongly recommended in production. LangSmith shows the graph tree, each node's I/O, LLM calls per node, latency waterfalls, and lets you filter by tags/metadata. Debugging a graph that took the wrong path via a text log is much harder than via a trace tree. Setup is a few env vars.

What was create_react_agent and what replaces it?

create_react_agent in langgraph.prebuilt was the early recommended entry point for ReAct-style agents. In LangChain 1.0 (October 2025), it was deprecated in favor of create_agent in the langchain package — same underlying LangGraph runtime, cleaner API, middleware system. For new work use create_agent; migrate existing create_react_agent code. See the LangChain pillar for the migration path.

Chapter 14

The full library — 32 LangGraph error pages

Every LangGraph error-fix page on AI Error Hub, organized by category. Bookmark this section — when you hit a specific error, jump straight to the page that walks through it with tested code.