State and reducers
State is the only thing every node shares. Get the schema right and the graph writes itself; get it wrong and you will fight the runtime for a week.
The schema
from typing_extensions import TypedDict
class State(TypedDict):
question: str
documents: list[str]
answer: strTypedDict is the recommended shape. A dataclass gives you defaults, and a
Pydantic BaseModel gives you validation on every update — useful when
untrusted input reaches the graph, at the cost of validation on each step.
A node returns a partial update, not the whole state:
def retrieve(state: State) -> dict:
docs = search(state["question"])
return {"documents": docs}Return only what changed. The runtime merges it. Returning the full state works and is a habit worth breaking — it makes the diffs in a trace useless.
Reducers: the concept that trips everyone
By default, an update to a key replaces that key. That is fine for
answer, and wrong for anything accumulating.
A reducer says how to combine the old value with the update:
from typing import Annotated
from operator import add
class State(TypedDict):
question: str
logs: Annotated[list[str], add]Now return {"logs": ["retrieved 5 docs"]} appends instead of replacing.
| Without reducer | With add |
|---|---|
| last write wins | updates accumulate |
| fine for scalars | right for lists |
| loses parallel work | merges parallel work |
That last row is the one that bites. Two nodes running in parallel both writing the same key without a reducer is a conflict, and the runtime will tell you so. With a reducer it is a merge. So the reducer is not a convenience — it is how you declare that a key is safe to write concurrently.
add_messages, the one you will actually use
Conversation history needs more than append: it needs updates by id, so a node can revise a message rather than duplicate it.
from typing import Annotated
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]add_messages appends new messages and replaces any message whose id
matches one already present. That is what lets you correct or remove a turn
without rebuilding the list.
The shortcut, when messages are all you need:
from langgraph.graph import MessagesState
class State(MessagesState):
documents: list[str]MessagesState is a TypedDict with messages already annotated. Subclass it
and add your own keys.
Designing the schema
Three rules that prevent most rewrites.
1. State is the contract between nodes, not a scratchpad. If only one node reads a key, it should probably be a local variable. Everything in state is checkpointed, streamed and inspected, so a key that exists for one node’s convenience is noise in every trace.
2. Keep it serialisable. State is persisted between steps, so a database connection or an open file handle does not belong in it. Put the id in state and resolve the object inside the node.
3. Watch the size.
A checkpoint stores only the channels that changed in that superstep, but a
changed channel is written in full. messages changes every step, so the
whole conversation is re-serialised each time: bytes per thread grow with
the square of its length.
Accumulating raw documents in a channel that changes each step means a copy per step. Store an id or a summary and fetch the body when needed — the same discipline as Context engineering.
Gotcha: a key with no reducer written by two parallel branches raises an error at runtime, not at build time. If you fan out, every key the branches write needs a reducer — that is the design question fan-out forces.
Custom reducers
When add is not the semantics you want, write one. It takes the current
value and the update, and returns the new value:
def keep_best(current: dict, update: dict) -> dict:
"""Keep whichever candidate scored higher."""
if not current:
return update
return max(
current, update, key=lambda d: d["score"]
)
class State(TypedDict):
best: Annotated[dict, keep_best]This is the clean way to express “several workers propose, one answer survives” — the merge rule lives in the schema rather than in a node that has to know about its siblings.
Related
Interview angle 5
- “How does state work in LangGraph?” - a typed dict threaded through every node. Nodes return partial updates and the runtime merges them; by default an update replaces the key.
- “What is a reducer?” - a function declaring how an update combines with the current value, attached with
Annotated.addturns replacement into accumulation, and it is what makes a key safe for two parallel branches to write. - “What does
add_messagesdo thatadddoesn’t?” - it appends new messages but replaces any whose id matches an existing one, so a node can revise or delete a turn instead of duplicating it. - “What happens if two parallel nodes write the same key?” - without a reducer it is a conflict and errors at runtime, not at build time. Fanning out forces you to decide the merge semantics for every key the branches touch.
- “What shouldn’t go in state?” - anything unserialisable, like a live connection, and anything only one node reads. State is checkpointed and streamed in full on every step, so it is a cost as well as a contract.