LangGraph TypedDict & Pydantic State Schema Errors — Fix Guide (2026)
State Management · Schema Severity: High

LangGraph TypedDict & Pydantic State Schema Errors

A state schema that looks fine in isolation but crashes the graph at runtime because a node returned a key that isn't declared, a Pydantic validator rejected a value, or a reducer was annotated on the wrong type. Here's how to keep the schema and the runtime data locked in step.

TL;DRState schema errors mean the shape LangGraph enforces at compile time doesn't match the data your nodes actually produce. Use TypedDict for simple graphs, Pydantic BaseModel when you need validation, and always annotate reducer-bearing fields with Annotated[T, reducer_fn] — otherwise updates get overwritten instead of merged.

Real error messages you'll see

InvalidUpdateError — unknown key
InvalidUpdateError — unknown key
langgraph.errors.InvalidUpdateError: Unknown key 'answer' in state update. Declared keys: ['messages', 'question', 'context'].
  at Pregel.astream() — node 'generate' returned {"answer": "..."} but the State schema has no 'answer' field.
Pydantic ValidationError
Pydantic ValidationError
pydantic_core._pydantic_core.ValidationError: 1 validation error for State
temperature
  Input should be a valid number [type=float_parsing, input_value='high', input_type=str]
  For further information visit https://errors.pydantic.dev/2.6/v/float_parsing
TypeError on reducer
TypeError on reducer
TypeError: unsupported operand type(s) for +: 'NoneType' and 'list'
  at add_messages reducer — the initial state didn't include the 'messages' key at all, so the reducer received None. Add a default or use TypedDict(total=False).

TypedDict vs Pydantic — pick the right schema

Use caseRecommendedWhy
Simple graphs, dev-only validationTypedDictZero runtime overhead; static type checking only.
Fields that need validation (ranges, enums, formats)BaseModelPydantic validates on every state update.
You want dot-attribute access (state.messages)BaseModelTypedDict only supports state["messages"].
Optional keysTypedDict(total=False) or Optional[T]Prevents KeyError on nodes that read before write.
Reducer on a keyAnnotated[list, add_messages]Works with both TypedDict and BaseModel.
You want frozen stateBaseModel(model_config=ConfigDict(frozen=True))Enforces immutability — nodes must return new state, not mutate in place.

Root causes (ranked by frequency)

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

  • 26%
    Node returns a key not declared in the schema. You added {"answer": "..."} to a node's return but the State schema only declares messages and question. LangGraph raises InvalidUpdateError.
  • 19%
    Field missing from the initial state. Graph starts with app.invoke({"question": "..."}) but the schema requires messages and a node tries to read it. Either mark the field optional or provide a default in the invoke call.
  • 15%
    Reducer not applied where expected. You wrote messages: list[AnyMessage] instead of messages: Annotated[list, add_messages]. Each node overwrites the previous value instead of appending.
  • 11%
    Pydantic validator rejected the value. A node produced a string when the field expected an int, or a list where a single item was declared. ValidationError at the boundary.
  • 10%
    Mixing TypedDict and Pydantic in nested structures. A TypedDict field typed as a nested Pydantic model doesn't auto-validate; a Pydantic field typed as a nested TypedDict doesn't enforce keys.
  • 8%
    Optional fields declared with Optional[T] but no default. Pydantic requires Optional[T] = None or Field(default=None)Optional[T] alone still means "required, but can be None".
  • 6%
    Passing extra keys to app.invoke. Some LangGraph versions strip unknown keys silently; others raise. If the schema is strict, invoke input must match.
  • 5%
    Reducer function itself throws. A custom reducer like lambda a, b: a + b fails when either side is None. Always default the base and guard the incoming value.

How to fix it

Fix #1

Use TypedDict with Annotated reducers for simple graphs

The default choice for 80% of LangGraph projects.

TypedDict is lightweight, has zero runtime cost, and plays perfectly with LangGraph's reducer system. The one rule: every field that needs to accumulate across nodes (messages, tool calls, evaluations) must be annotated with a reducer function. Every field that is simply overwritten (current step, classification label) can be a plain type.

typeddict_state.pypython
from typing import Annotated, TypedDict, Optional
from operator import add
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages


class State(TypedDict):
    # Accumulating field — messages APPEND across nodes (add_messages handles IDs)
    messages: Annotated[list[AnyMessage], add_messages]

    # Accumulating list of arbitrary items — use operator.add
    citations: Annotated[list[str], add]

    # Overwritten field — plain type, no reducer
    question: str

    # Optional field — use total=False on the class, or Optional[T]
    classification: Optional[str]


# ✅ Node returns a full update
def classifier(state: State) -> dict:
    q = state["question"]
    label = classify(q)
    return {"classification": label}          # overwrites


# ✅ Node appends to messages via the add_messages reducer
def responder(state: State) -> dict:
    reply = llm.invoke(state["messages"])
    return {"messages": [reply]}              # appended, not replaced


# ✅ Node appends citations
def cite(state: State) -> dict:
    return {"citations": ["doc-42", "doc-99"]}   # merged with prior citations


# Initial state provides every REQUIRED key
initial = {
    "messages": [],
    "citations": [],
    "question": "What is LangGraph?",
    "classification": None,
}
result = app.invoke(initial)
Note: If a field is only sometimes set, declare the whole TypedDict with total=False — that way missing keys aren't a validation error and every node can use state.get(key, default) defensively.
Fix #2

Use Pydantic BaseModel when you need real validation

Choose this when values have constraints or the schema is a public API.

Pydantic gives you runtime validation on every state update — type coercion, ranges, enums, custom validators. That safety costs a small amount of per-node overhead, so use it when the state contains external data (user input, tool outputs) that could be malformed. LangGraph 0.2+ supports Pydantic v2 BaseModel as a State schema natively.

pydantic_state.pypython
from typing import Annotated, Optional, Literal
from pydantic import BaseModel, Field, field_validator
from langchain_core.messages import AnyMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages


class State(BaseModel):
    # Reducer-bearing fields work the same way with Annotated
    messages: Annotated[list[AnyMessage], add_messages] = Field(default_factory=list)

    # Validated fields
    question: str = Field(min_length=1, max_length=2000)
    temperature: float = Field(ge=0.0, le=2.0, default=0.7)
    mode: Literal["chat", "search", "math"] = "chat"

    # Optional with default — the RIGHT way to say "may be missing"
    classification: Optional[str] = None
    retry_count: int = Field(ge=0, default=0)

    @field_validator("question")
    @classmethod
    def strip_whitespace(cls, v: str) -> str:
        v = v.strip()
        if not v:
            raise ValueError("question cannot be empty after stripping whitespace")
        return v


def classifier(state: State) -> dict:
    # state is a BaseModel instance — dot access works
    label = classify(state.question)
    return {"classification": label}


graph = StateGraph(State)                     # <-- pass the BaseModel class
graph.add_node("classifier", classifier)
graph.add_edge(START, "classifier")
graph.add_edge("classifier", END)

app = graph.compile()

# Invoke with a dict — Pydantic validates on the way in
result = app.invoke({"question": "  what is LangGraph?  "})
# Passing invalid input raises ValidationError before any node runs:
# result = app.invoke({"question": "", "temperature": 5.0})   # -> ValidationError
Note: Pydantic runs validators on every state update, not just the initial input — so a node that returns {"temperature": 5.0} will raise ValidationError mid-graph. That's the point, but it means node returns need to be as clean as the initial input.
Fix #3

Annotate every accumulating field with the right reducer

Prevents silent overwrite when two nodes both write to the same key.

A LangGraph field without a reducer is overwritten on every update — state["messages"] = new_messages, not state["messages"].extend(new_messages). That's fine for a "current classification" field, but wrong for messages, citations, tool calls, and anywhere a node produces incremental output. Miss the reducer once and you'll spend two hours wondering why earlier messages vanished mid-graph.

reducers.pypython
from typing import Annotated, TypedDict
from operator import add
from langgraph.graph.message import add_messages


# ❌ BUG — no reducer, messages get overwritten every node
class BadState(TypedDict):
    messages: list         # <-- just list, no Annotated
    tool_calls: list


# In this graph, node_b sees only its own messages — node_a's are LOST
def node_a(state):
    return {"messages": [("system", "a's message")]}

def node_b(state):
    # state["messages"] is JUST [("system", "a's message")] here,
    # then node_b overwrites it entirely
    return {"messages": [("system", "b's message")]}
# Final state: messages = [("system", "b's message")]


# ✅ FIX — annotate messages with add_messages (special-case reducer for chat)
class GoodState(TypedDict):
    messages: Annotated[list, add_messages]     # appends, handles message IDs
    tool_calls: Annotated[list, add]            # plain concat for other lists

# Same nodes as above, but with add_messages reducer:
# Final state: messages = [
#     ("system", "a's message"),
#     ("system", "b's message"),
# ]


# ✅ Custom reducer — merge dicts by key
def merge_dict(current: dict, update: dict) -> dict:
    return {**(current or {}), **(update or {})}

class StateWithDict(TypedDict):
    metadata: Annotated[dict, merge_dict]


# Guard: reducers receive None on first call — always default the base
def safe_concat(current: list | None, update: list) -> list:
    return (current or []) + (update or [])

class StateWithSafeReducer(TypedDict):
    events: Annotated[list, safe_concat]
Note: The add_messages reducer is smarter than operator.add for chat — it deduplicates by message ID, which matters when a checkpoint replays messages. Use add_messages for any list[AnyMessage]; use operator.add for plain lists of strings, dicts, or objects.

Prevention checklist

  • Choose TypedDict for simple graphs; upgrade to Pydantic BaseModel only when you need validation.
  • Annotate every accumulating field with a reducer: Annotated[list, add_messages] for messages, Annotated[list, operator.add] for other lists.
  • Mark optional fields explicitly: total=False on TypedDict, or Optional[T] = None on Pydantic.
  • Every custom reducer must handle None on the "current" side — it fires on the first update.
  • Node returns should only touch declared state keys. Extra keys raise InvalidUpdateError.
  • Provide defaults for every required field in the initial invoke input — even if it's just [] or None.
  • Type-check node signatures with -> dict so mypy/pyright flag missing returns before runtime.

Frequently asked questions

Should I default to TypedDict or Pydantic for a new graph?

TypedDict. It has zero runtime overhead and integrates naturally with LangGraph's reducer system. Upgrade to Pydantic BaseModel only when you need one or more of: runtime validation, coercion of user input, custom validators, or dot-attribute access to state. Most production LangGraph agents use TypedDict end-to-end.

Can I mix TypedDict and Pydantic inside one State?

Yes, but be aware of the boundary. A TypedDict field whose type is a Pydantic model does not auto-validate on assignment — LangGraph passes the value through as-is. A Pydantic BaseModel field whose type is a TypedDict also skips deep validation. If validation is important, keep the whole state as Pydantic and nest BaseModel submodels.

Why is my <code>add_messages</code> reducer duplicating messages?

add_messages deduplicates by id. If your messages don't have IDs (raw tuples, or a custom message class without an id field), it treats each occurrence as unique and appends. Either use LangChain's AIMessage/HumanMessage classes (they auto-assign IDs) or set an explicit id field on your custom message type.

Can I have a field with no reducer that's still updated by parallel nodes?

Not safely. When two parallel nodes both write to a non-reduced field, the last write wins — silently. That's a data-loss bug. If a field is touched by parallel branches, you must annotate it with a reducer that merges updates deterministically. If parallel access is impossible in your graph, a plain field is fine, but consider it a lint smell to double-check.

How do I add a new field to State without breaking existing checkpoints?

Give the new field a default value (either Optional[T] = None in Pydantic, or make the whole TypedDict total=False). Old checkpoints deserialize without the field; new nodes fill it in. If the field is required (no default), old checkpoints will raise ValidationError on resume. Always default new fields and migrate later if you want to enforce presence.

Related errors