LangGraph Reducer Functions & add_messages Update Errors
The reducer is the little annotation that decides whether a field appends or overwrites. Get it right and parallel nodes merge cleanly; get it wrong and half your messages silently disappear between nodes. Here's the full picture of how reducers work and where they fail.
By Ahmed R. · Last updated Aug 14, 2026 · LangGraph · Page #125
(current, update) -> new that LangGraph calls to merge state updates. Without a reducer, updates overwrite. With Annotated[list, add_messages], updates append and deduplicate by ID. The three most common bugs: no reducer on a field that needed one, custom reducer that crashes on None, and message IDs missing so add_messages appends the same message twice.Real error messages you'll see
# No traceback, just wrong results.
# Initial: {"messages": [system_msg]}
# After node_a: {"messages": [system_msg, a_msg]} ← correct
# After node_b: {"messages": [b_msg]} ← WRONG, prior messages gone
# Root cause: State field declared as `messages: list` without the add_messages reducer.
TypeError: can only concatenate list (not "NoneType") to list
at reducer merge_lists(current=None, update=[...])
# Reducer receives None on the first update — the "current" side hasn't been initialized yet.
# Fix: default the current side inside the reducer.
# Symptom: the same assistant reply appears 2-3 times in the final state.
# Root cause: messages have no `id` field, so add_messages treats each occurrence as unique.
# Fix: use LangChain message classes (AIMessage/HumanMessage) which auto-assign IDs,
# or set an explicit id on custom message objects before returning.
Built-in and common custom reducers
| Reducer | Semantics | Use for |
|---|---|---|
add_messages | Append + deduplicate by ID; supports updates via matching ID | list[AnyMessage] — always use for chat history |
operator.add | Plain concatenation: a + b | list[str], list[dict] where duplicates are fine |
lambda cur, upd: {**(cur or {}), **upd} | Shallow dict merge (upd wins on conflict) | dict metadata that accumulates across nodes |
lambda cur, upd: max(cur or 0, upd) | Take the maximum | Progress counters, confidence scores |
lambda cur, upd: upd | Replace (explicit no-op reducer) | Documents the intent to overwrite |
| None (no annotation) | Overwrite (last write wins) | Single-writer fields; not safe for parallel nodes |
Root causes (ranked by frequency)
Based on LangGraph developer reports; percentages sum to 100%.
- 27%Field needs a reducer but has none.
messages: listinstead ofmessages: Annotated[list, add_messages]. Every update replaces the whole list — earlier messages are silently lost. - 20%Custom reducer crashes on
None. Reducer written aslambda cur, upd: cur + upd— butcurisNonethe first time. Always default:(cur or []) + upd. - 16%Message objects have no
id. Custom message tuples like("assistant", "hi")or dicts don't have IDs.add_messagescan't deduplicate them, so replays and parallel branches double up messages. - 12%Reducer used on a field that shouldn't accumulate. Annotated
classification: Annotated[str, add]— every classification label gets concatenated instead of replaced. Drop the reducer or use one that replaces. - 9%Parallel nodes both write; only one has a reducer. If field
tool_resultshas a reducer but node_a writes to it and node_b writes totool_output(no reducer), the second field silently loses data on merge. - 7%Reducer function has side effects. Reducer that logs, mutates a global, or reads from disk — LangGraph may call it multiple times during retries. Reducers should be pure functions.
- 5%Reducer returns wrong type. Declared field is
list[str]but reducer returns atuple. Downstream node reads assume list and crash. - 4%Version mismatch:
add_messagesAPI changed. Very old LangGraph pinned in arequirements.txt;add_messagesbehaviors around IDs and delete tombstones differ. Upgrade to 0.2+.
How to fix it
Use add_messages on every messages field — no exceptions
The one-line fix for "half my messages disappeared".
The single most common LangGraph state bug is a messages field without add_messages. It looks fine in tests with one node, silently corrupts data as soon as two nodes both write. Make it a reflex: every messages field is Annotated[list[AnyMessage], add_messages]. Every time.
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage, AIMessage, HumanMessage, SystemMessage
from langgraph.graph.message import add_messages
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages] # ← THE fix
def node_a(state):
# Use real message classes (they auto-assign IDs — critical for dedup)
return {"messages": [AIMessage(content="hello from a")]}
def node_b(state):
# add_messages merges by id — no double-append on retry
return {"messages": [AIMessage(content="hello from b")]}
graph = StateGraph(State)
graph.add_node("a", node_a)
graph.add_node("b", node_b)
graph.add_edge(START, "a")
graph.add_edge("a", "b")
graph.add_edge("b", END)
app = graph.compile()
result = app.invoke({"messages": [SystemMessage(content="You are helpful.")]})
# result["messages"] contains ALL 3 messages, in order:
# SystemMessage, AIMessage("hello from a"), AIMessage("hello from b")
# ✅ Updating a prior message by ID — add_messages will REPLACE it
def edit_node(state):
prior = state["messages"][-1]
replacement = AIMessage(content="edited content", id=prior.id) # same id
return {"messages": [replacement]} # replaces, not appends
# ✅ Deleting a message via tombstone (LangGraph 0.2+)
from langchain_core.messages import RemoveMessage
def delete_last(state):
target_id = state["messages"][-1].id
return {"messages": [RemoveMessage(id=target_id)]}
AIMessage, HumanMessage, SystemMessage, ToolMessage). They auto-assign IDs, work with add_messages dedup, and integrate with LangSmith tracing. Raw tuples ("assistant", "text") work but skip the ID system.Write custom reducers that handle None defensively
Fixes the TypeError: can only concatenate list (not "NoneType") to list.
A reducer is called with (current_value, update_value). On the first update, current_value is whatever the initial state provided — None if the field wasn't initialized. Every custom reducer needs to handle that case. Two patterns work: default the current side inside the reducer, or ensure the initial state always provides a non-None value.
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
# ❌ BUG — crashes when current is None
def bad_merge(current: list, update: list) -> list:
return current + update # TypeError if current is None
# ✅ Pattern 1: default the current side inside the reducer
def safe_merge(current: list | None, update: list | None) -> list:
return (current or []) + (update or [])
# ✅ Pattern 2: strict dict merge that preserves prior on empty update
def merge_meta(current: dict | None, update: dict | None) -> dict:
if not update:
return current or {}
return {**(current or {}), **update}
# ✅ Pattern 3: bounded accumulator — cap the list at N items
def last_n(cap: int):
def reducer(current: list | None, update: list | None) -> list:
merged = (current or []) + (update or [])
return merged[-cap:]
return reducer
class State(TypedDict):
citations: Annotated[list, safe_merge]
metadata: Annotated[dict, merge_meta]
recent_events: Annotated[list, last_n(20)] # only keep last 20
# All three reducers now survive an empty initial state
initial = {} # nothing set
app.invoke(initial) # ← works; reducers use their None-defaults
Ensure messages have IDs — always use LangChain message classes
Fixes duplicate messages appearing 2-3x in the final state.
add_messages's dedup relies on the id attribute. If you return raw tuples like ("assistant", "hi") or plain dicts, they lack IDs, and every occurrence — whether from a retry, a parallel branch, or a checkpoint replay — will be treated as unique. The fix is trivial: always use LangChain's message classes, which auto-assign IDs when instantiated.
from langchain_core.messages import (
AIMessage, HumanMessage, SystemMessage, ToolMessage, AnyMessage
)
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
# ❌ Raw tuple — no ID, add_messages can't dedup
def bad_node(state):
return {"messages": [("assistant", "hi")]} # tuple has no .id
# ❌ Plain dict — no ID field
def also_bad(state):
return {"messages": [{"role": "assistant", "content": "hi"}]}
# ✅ LangChain message class — auto-assigned UUID as id
def good_node(state):
msg = AIMessage(content="hi")
print(msg.id) # e.g. "run-abc123..."
return {"messages": [msg]}
# ✅ Explicit ID (useful for editing / replacing a specific message)
def edit_previous(state):
prior = state["messages"][-1]
return {"messages": [AIMessage(content="corrected", id=prior.id)]}
# ✅ Tool response — always use ToolMessage with the tool_call_id
def handle_tool_result(state, result):
tool_call = state["messages"][-1].tool_calls[0]
return {"messages": [ToolMessage(
content=result,
tool_call_id=tool_call["id"], # links response to the request
)]}
# When migrating from tuples: convert once at the boundary
def normalize_messages(msgs):
normalized = []
for m in msgs:
if isinstance(m, tuple):
role, content = m
cls = {"system": SystemMessage, "user": HumanMessage,
"assistant": AIMessage}[role]
normalized.append(cls(content=content))
else:
normalized.append(m)
return normalized
id before returning: msg = AIMessage(content=text, id=f"node-{node_name}-{uuid.uuid4()}"). Dedup then works, but you own the ID space.Prevention checklist
- Every
messages: list[AnyMessage]field must be annotated withadd_messages. Treat missing reducer here as a bug, not a style choice. - Every custom reducer defaults the "current" side to a sensible empty value (
[],{},0). - Always use LangChain's message classes (
AIMessage,HumanMessage,ToolMessage) so IDs are auto-assigned. - Reducers must be pure — no logging, no I/O, no global mutation.
- A field touched by parallel nodes must have a reducer that merges deterministically.
- Use
RemoveMessage(id=...)to delete a message viaadd_messages— don't mutate the list directly. - When in doubt, test the reducer in isolation:
reducer(None, update)andreducer(prior, [])both need to work.
Frequently asked questions
Append when the update contains a message with an ID not already in the current state. Replace when the update contains a message with an ID that is already there — this lets you edit a prior message by returning a new one with the same ID. To delete, return RemoveMessage(id=target_id). All three operations happen through the same reducer.
No. A field has exactly one reducer. If you need composite behavior (append + cap + validate), write a single custom reducer that does all three. That's actually cleaner than chaining — the whole merge logic lives in one function that's trivial to unit-test.
Usually one of three reasons: checkpoint replay (the graph resumed from a checkpoint and re-applied updates), a retry after a transient error, or you're calling app.invoke multiple times without resetting state. This is why reducers must be pure — side-effect-free reducers are idempotent under repeated application. If you need exactly-once semantics, put the side effect inside the node body and let the reducer just merge values.
Yes, when the streaming source emits messages with stable IDs. Each partial chunk with the same ID replaces the prior partial in state, so the final state reflects the completed message. If your streaming source doesn't provide IDs, buffer chunks in the node and only emit the completed message to state at the end. The stream itself (visible via astream_events) is separate from what lands in state.
Same as any other: Annotated[MyModel, my_reducer]. The reducer receives two MyModel instances (or None + one) and returns a merged instance. Common pattern: lambda cur, upd: cur.model_copy(update=upd.model_dump(exclude_unset=True)) if cur else upd. That merges only the fields the update actually set, preserving prior values elsewhere.