LangGraph Reducer Functions & add_messages Update Errors — Fix Guide (2026)
State Management · Reducers Severity: High

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.

TL;DRA reducer is a function (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

Silent overwrite — messages missing from final state
Silent overwrite — messages missing from final state
# 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 in custom reducer
TypeError in custom 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.
Duplicate messages from add_messages
Duplicate messages from add_messages
# 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

ReducerSemanticsUse for
add_messagesAppend + deduplicate by ID; supports updates via matching IDlist[AnyMessage] — always use for chat history
operator.addPlain concatenation: a + blist[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 maximumProgress counters, confidence scores
lambda cur, upd: updReplace (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: list instead of messages: Annotated[list, add_messages]. Every update replaces the whole list — earlier messages are silently lost.
  • 20%
    Custom reducer crashes on None. Reducer written as lambda cur, upd: cur + upd — but cur is None the 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_messages can'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_results has a reducer but node_a writes to it and node_b writes to tool_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 a tuple. Downstream node reads assume list and crash.
  • 4%
    Version mismatch: add_messages API changed. Very old LangGraph pinned in a requirements.txt; add_messages behaviors around IDs and delete tombstones differ. Upgrade to 0.2+.

How to fix it

Fix #1

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.

messages_reducer.pypython
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)]}
Note: Always use LangChain's message classes (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.
Fix #2

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.

safe_reducers.pypython
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
Note: Reducers must be pure functions. Do not log, mutate globals, or perform I/O inside a reducer — LangGraph may call them multiple times during retries or checkpointing, and side effects will fire more often than you expect.
Fix #3

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.

message_ids.pypython
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
Note: If you must keep raw tuples for interop reasons, set an explicit 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 with add_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 via add_messages — don't mutate the list directly.
  • When in doubt, test the reducer in isolation: reducer(None, update) and reducer(prior, []) both need to work.

Frequently asked questions

When does add_messages append vs replace?

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.

Can I use multiple reducers on one field?

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.

Why does my reducer fire multiple times for the same update?

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.

Can add_messages handle streaming partial messages?

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.

How do I write a reducer for a field that's a Pydantic model?

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.

Related errors