LangGraph StateGraph Node & Edge Configuration Errors — Fix Guide (2026)
Graph Construction · Nodes & Edges Severity: High

LangGraph StateGraph Node & Edge Configuration Errors

The most common LangGraph error class in 2026: your graph refuses to compile or blows up on the first invocation because a node name doesn't line up, a node function returned None, or an edge points at something that doesn't exist yet. Here are the three fixes that solve 90% of cases.

TL;DRA StateGraph node/edge error almost always means (a) an edge references a node string that was never added, (b) a node callable returned None or a non-dict value, or (c) you called add_edge before the target node existed. Fix: add all nodes first, then edges, and make every node return a partial state dict — never None.

Real error messages you'll see

ValueError at compile()
ValueError at compile()
ValueError: Node 'grade_documents' not found. Available nodes: ['retrieve', 'generate'].
  at StateGraph.compile()
# LangGraph refuses to build the graph because an add_edge / add_conditional_edges call references a node string you never registered.
InvalidUpdateError at runtime
InvalidUpdateError at runtime
langgraph.errors.InvalidUpdateError: Expected dict, got None
For node 'planner', state update returned None. Every node must return a dict of state keys to update, or an empty dict {} if nothing changes.
  at Pregel.astream() / Pregel.invoke()
KeyError from missing state key
KeyError from missing state key
KeyError: 'messages'
  at node 'generate' — attempted to read state['messages'] but the key was never set. The initial input to graph.invoke({...}) must include every state key the first node reads, or the schema must give it a default.

Node signature quick-reference

Return valueEffectUse when
{"messages": [msg]}Merged into state via the reducer for that key (append for add_messages)Standard case — a node produced output
{}No state change — graph advances to next nodeNode performed a side-effect (log, tool call) but has nothing to update
NoneInvalidUpdateError — LangGraph rejects itNever — always return a dict
Command(update={...}, goto="x")Update state AND jump to node "x"Dynamic routing without add_conditional_edges
{"foo": bar} where foo is not in schemaInvalidUpdateError — unknown keyNever — update only declared state keys

Root causes (ranked by frequency)

Based on LangGraph developer reports; percentages sum to 100%.

  • 34%
    Edge references a node that was never added. You called graph.add_edge("retrieve", "grade_documents") but there is no graph.add_node("grade_documents", ...) above it. Order matters — nodes must exist before edges reference them.
  • 22%
    Node function returned None implicitly. The Python function has no return statement or returns nothing on some branch. LangGraph treats that as an InvalidUpdateError; every node must return at minimum {}.
  • 14%
    Node returned a non-dict (list, string, message). A common mistake is return response where response is an AI message object. Wrap it: return {"messages": [response]}.
  • 11%
    Typo in node name. add_node("retriever", ...) then add_edge("retreiver", END). LangGraph will not fuzzy-match; the string must be exact.
  • 9%
    Node updated a state key that is not in the schema. If State declares messages and question, returning {"answer": "..."} raises InvalidUpdateError: unknown key.
  • 6%
    Two nodes registered with the same name. add_node("agent", ...) twice — the second call silently overwrites the first in some versions and raises ValueError in others.
  • 4%
    Using the START or END sentinel as a node name. These are reserved. You cannot add_node("START", ...); use set_entry_point("my_node") or add_edge(START, "my_node").

How to fix it

Fix #1

Add every node before any edge touches it

Fixes the ValueError "Node not found" at compile time.

LangGraph doesn't auto-declare nodes when you reference them in edges. Build in strict order: StateGraph → all add_node calls → then set_entry_point / add_edge / add_conditional_edges → then compile(). When the graph is large, put each node definition and its edge into a single block so the two stay in sync when you refactor.

graph_builder.pypython
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import AnyMessage


class State(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]
    question: str


def retrieve(state: State) -> dict:
    # ... call vector store here ...
    docs = ["doc1", "doc2"]
    return {"messages": [("system", f"context: {docs}")]}


def grade_documents(state: State) -> dict:
    # ... judge relevance ...
    return {"messages": [("system", "grade: relevant")]}


def generate(state: State) -> dict:
    # ... call the LLM ...
    return {"messages": [("assistant", "final answer")]}


# 1. Register EVERY node first
graph = StateGraph(State)
graph.add_node("retrieve", retrieve)
graph.add_node("grade_documents", grade_documents)   # <-- add BEFORE the edge below
graph.add_node("generate", generate)

# 2. Then wire the edges
graph.add_edge(START, "retrieve")
graph.add_edge("retrieve", "grade_documents")
graph.add_edge("grade_documents", "generate")
graph.add_edge("generate", END)

# 3. Compile — this is where the ValueError would fire if a node is missing
app = graph.compile()
Note: If you're building the graph dynamically in a loop, keep a set() of registered node names and assert that every edge endpoint is in that set before calling compile(). Fails loudly at build time instead of at first invocation.
Fix #2

Make every node return a dict — never None, never a bare object

Fixes InvalidUpdateError: Expected dict, got None (or list, or Message).

A node that runs but doesn't modify state must return {}. A node that produced an AI message must wrap it: {"messages": [msg]}. If any branch of your function is silent, add an explicit return {}. This is the single most common LangGraph runtime error in production.

node_returns.pypython
from langchain_core.messages import AIMessage

# ❌ Bug: returns None on the "no docs" branch
def bad_node(state):
    if state["docs"]:
        response = llm.invoke(state["messages"])
        return {"messages": [response]}
    # implicit return None → InvalidUpdateError at runtime

# ❌ Bug: returns the AIMessage directly, not a dict
def bad_node_2(state):
    response = llm.invoke(state["messages"])
    return response   # → InvalidUpdateError: Expected dict

# ✅ Correct: every branch returns a dict
def good_node(state):
    if not state["docs"]:
        return {}                              # no state change, but valid
    response = llm.invoke(state["messages"])
    return {"messages": [response]}            # always dict

# ✅ Multi-key update
def planning_node(state):
    plan = build_plan(state["question"])
    return {"plan": plan, "step_index": 0}     # dict of state keys → new values

# ✅ Using Command for state update + explicit routing (LangGraph 0.2+)
from langgraph.types import Command
def router_node(state) -> Command:
    if state["is_math"]:
        return Command(update={"tool": "calculator"}, goto="tool_use")
    return Command(update={"tool": None}, goto="generate")
Note: A quick sanity check: run ruff check --select RET on your node functions — it flags implicit None returns before you ship.
Fix #3

Validate node names against the graph before compile

Prevents typos from turning into runtime blowups.

When you have more than ~6 nodes, hand-tracking node strings across add_edge calls is where "retreiver" vs "retriever" creeps in. Wrap graph construction in a helper that references a single frozen set of node names, so a typo is a NameError at import time instead of a ValueError at compile.

safe_graph.pypython
from enum import Enum
from langgraph.graph import StateGraph, START, END

class N(str, Enum):
    """Frozen enum of node names — typos become NameError at import."""
    RETRIEVE = "retrieve"
    GRADE = "grade_documents"
    GENERATE = "generate"
    REWRITE = "rewrite_query"


def build_graph(State):
    g = StateGraph(State)
    g.add_node(N.RETRIEVE, retrieve)
    g.add_node(N.GRADE, grade_documents)
    g.add_node(N.GENERATE, generate)
    g.add_node(N.REWRITE, rewrite_query)

    g.add_edge(START, N.RETRIEVE)
    g.add_edge(N.RETRIEVE, N.GRADE)
    g.add_conditional_edges(
        N.GRADE,
        lambda s: N.GENERATE if s["relevant"] else N.REWRITE,
        {N.GENERATE: N.GENERATE, N.REWRITE: N.REWRITE},
    )
    g.add_edge(N.REWRITE, N.RETRIEVE)
    g.add_edge(N.GENERATE, END)

    # Optional: assert graph is well-formed before compile
    declared = {N.RETRIEVE.value, N.GRADE.value, N.GENERATE.value, N.REWRITE.value}
    referenced = {e for pair in g.edges for e in pair if e not in (START, END)}
    missing = referenced - declared
    assert not missing, f"Edges reference undeclared nodes: {missing}"

    return g.compile()
Note: The enum trick catches ~80% of node-name errors before you ever run compile(). Cheap insurance for graphs with 10+ nodes.

Prevention checklist

  • Always register every node with add_node before any edge references it. Treat this as a lint rule.
  • Every node function must return a dict — even if empty. Add -> dict return type hints so mypy/pyright flag missing returns.
  • Use an Enum or Literal type for node names when your graph has more than 6 nodes.
  • Return only state keys that exist in your State schema. Any unknown key raises InvalidUpdateError.
  • Never register a node named "START" or "END" — they're reserved sentinels.
  • When wrapping AI message objects, always do {"messages": [msg]}, not {"messages": msg}. The reducer expects an iterable.
  • Run graph.get_graph().draw_mermaid() after compile() in a smoke test — visualizing the graph catches unreachable nodes and dead ends immediately.

Frequently asked questions

Why does LangGraph require me to return an empty dict for a no-op node?

Because LangGraph merges every node's return value into the graph state via the per-key reducers. A None return is ambiguous — did the node crash silently, forget to return, or intend no update? An empty dict {} is the explicit signal for "advance, no state change." This is intentional strictness; it prevents a whole class of "why is my state stale?" bugs.

Can I add a node after I've already added edges?

Yes — StateGraph lets you interleave add_node and add_edge calls until you call compile(). The validation only fires at compile time. That said, keeping all add_node calls above all add_edge calls makes the code much easier to review and refactor.

Why does <code>set_entry_point</code> not accept the <code>START</code> sentinel?

set_entry_point("my_node") is equivalent to add_edge(START, "my_node"). You use one or the other, not both. Passing START to set_entry_point would be circular and raises a ValueError. In modern LangGraph code, prefer add_edge(START, "my_node") for symmetry with the rest of your edge declarations.

What's the difference between returning <code>{"messages": [msg]}</code> and using <code>Command(update=...)</code>?

Both update state. Command additionally lets a node dictate the next node via goto="target", bypassing add_conditional_edges. Use plain dict returns when your routing is static (defined by edges) and use Command when the same node needs to update state and pick its own successor dynamically — common in agent supervisors.

Does LangGraph re-run a node if its return value fails validation?

No. When a node returns an invalid update (None, wrong type, unknown key), LangGraph raises InvalidUpdateError and the graph invocation terminates. There is no automatic retry. Retry logic is your responsibility — either wrap the node body in try/except and return {} on failure, or use LangGraph's built-in checkpointing to resume from the last valid step.

Related errors