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.
By Ahmed R. · Last updated Aug 14, 2026 · LangGraph · Page #121
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: 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.
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: '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 value | Effect | Use 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 node | Node performed a side-effect (log, tool call) but has nothing to update |
None | InvalidUpdateError — LangGraph rejects it | Never — 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 schema | InvalidUpdateError — unknown key | Never — 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 nograph.add_node("grade_documents", ...)above it. Order matters — nodes must exist before edges reference them. - 22%Node function returned
Noneimplicitly. The Python function has noreturnstatement or returns nothing on some branch. LangGraph treats that as anInvalidUpdateError; every node must return at minimum{}. - 14%Node returned a non-dict (list, string, message). A common mistake is
return responsewhereresponseis an AI message object. Wrap it:return {"messages": [response]}. - 11%Typo in node name.
add_node("retriever", ...)thenadd_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
Statedeclaresmessagesandquestion, returning{"answer": "..."}raisesInvalidUpdateError: 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 raisesValueErrorin others. - 4%Using the
STARTorENDsentinel as a node name. These are reserved. You cannotadd_node("START", ...); useset_entry_point("my_node")oradd_edge(START, "my_node").
How to fix it
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.
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()
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.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.
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")
ruff check --select RET on your node functions — it flags implicit None returns before you ship.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.
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()
compile(). Cheap insurance for graphs with 10+ nodes.Prevention checklist
- Always register every node with
add_nodebefore any edge references it. Treat this as a lint rule. - Every node function must return a
dict— even if empty. Add-> dictreturn type hints somypy/pyrightflag missing returns. - Use an
EnumorLiteraltype for node names when your graph has more than 6 nodes. - Return only state keys that exist in your
Stateschema. Any unknown key raisesInvalidUpdateError. - 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()aftercompile()in a smoke test — visualizing the graph catches unreachable nodes and dead ends immediately.
Frequently asked questions
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.
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.
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.
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.
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.