skip to content

What is a reducer in a LangGraph state schema, and when do you need one?

level: middleimportance: must knowfreq 72%

answer

  1. schema decides how writes combine
  2. annotation carries the merge rule
  3. default accepts one write per step
  4. concat for accumulation, id-merge for chat
  5. concurrent writes need a defined rule

basics

~20 s

A reducer is a function attached to a state key that says how a node's returned value combines with the existing one. Without it a key is last-value: one write per step, and a second concurrent write raises InvalidUpdateError. With operator.add, lists accumulate instead.

solid answer

~50 s

By default every key in a LangGraph state schema behaves as *last value wins* — a node's returned value replaces the old one, and the channel accepts exactly one write per superstep. A reducer overrides that combination rule. You attach it in the type annotation: `notes: Annotated[list[str], operator.add]` means each node's returned list is concatenated onto the accumulated one rather than replacing it. The reducer is called as `reducer(current_value, update)` for every write to that key. You need one in two situations. First, whenever a key is meant to accumulate across a loop — message history, retrieved documents, tool results — since without it each iteration wipes the last. Second, whenever two nodes can write the same key in the same superstep: a last-value channel raises `InvalidUpdateError` ("can receive only one value per step") because it has no defined way to reconcile them, and the reducer supplies that definition.

code

python · 27 lines
python
import operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END


class State(TypedDict):
    draft: str                                   # last value wins
    notes: Annotated[list[str], operator.add]    # accumulates


def step_one(state: State) -> dict:
    return {"draft": "v1", "notes": ["first"]}


def step_two(state: State) -> dict:
    return {"draft": "v2", "notes": ["second"]}


builder = StateGraph(State)
builder.add_node("one", step_one)
builder.add_node("two", step_two)
builder.add_edge(START, "one")
builder.add_edge("one", "two")
builder.add_edge("two", END)

# {'draft': 'v2', 'notes': ['first', 'second']}
print(builder.compile().invoke({"draft": "", "notes": []}))

go deeper

for a junior

Know that Annotated with operator.add makes a list key accumulate instead of being overwritten, and that plain keys are replaced by each write.

for a middle

Explain that the reducer is called with the current value and the node's update, that the default channel accepts one write per superstep, and why add_messages merges by id rather than appending.

for a senior

Show that you pick reducer semantics from the meaning of the key — accumulate, union, keep-best — and that you treat unbounded accumulating channels as a growth problem to plan for.

for a principal

Own the invariant: reducers must be pure and order-insensitive because concurrent writes have no defined order, and reproducibility of a run depends on that property holding across the whole schema.

## What a reducer is Each key in a LangGraph state schema is backed by a channel, and a channel has a rule for combining an incoming write with what it already holds. A reducer *is* that rule, expressed as a two-argument function: it takes the current value and the update a node returned, and produces the new value. LangGraph reads it from the type annotation, not from a separate registry — the schema is where the merge semantics live. ## The default is last value Declare `draft: str` and you get last-value semantics: the returned value replaces the old one. This is the right default for a scalar that represents "the current answer". It has one sharp property people miss — a last-value channel is defined to accept exactly **one** write per superstep. It is not "the last writer wins"; concurrent writes are an error, because ordering between nodes in the same superstep is not defined and silently choosing one would make runs non-reproducible. ## Declaring a reducer The spelling is `typing.Annotated`, whose second argument LangGraph interprets as the reducer: `notes: Annotated[list[str], operator.add]` `operator.add` on lists is concatenation, so a node returning `{"notes": ["a"]}` appends rather than replaces. The pattern generalises: `operator.or_` merges dicts, `operator.add` sums ints, and any plain function of two arguments works, so `Annotated[set[str], lambda old, new: old | new]` gives set union. Because the reducer is per key, one schema can freely mix accumulating and overwriting keys. A reducer must be **associative and order-insensitive in practice** if the key can receive concurrent writes, because the order in which two same-superstep updates hit the channel is not something you control. `operator.add` on lists satisfies this loosely — you get both lists, in an order you should not depend on. A reducer that subtracts, or that reads a clock, will produce results that vary run to run. ## add_messages The chat case has a purpose-built reducer, `add_messages` from `langgraph.graph.message`. Plain concatenation would be wrong for a message list: an agent that re-emits a message with the same id — because the model streamed it again, or a node rewrote it — would duplicate the turn. `add_messages` merges by message **id**: an update whose id already exists replaces that message in place, and only genuinely new messages are appended. It also coerces loosely typed inputs into message objects and honours `RemoveMessage` for deleting a turn from history, which is how message trimming is implemented without reaching around the state model. The prebuilt `MessagesState` is nothing more than a `TypedDict` with `messages` annotated this way, which is why so many graphs subclass it and add their own keys. ## The concurrency case This is what interviewers are really probing. As long as your graph is a straight line, every superstep has one writer per key and last-value semantics look like plain assignment. The moment two nodes are scheduled together and both return `result`, the last-value channel raises `InvalidUpdateError` with a message telling you to use an annotated key. The fix is not to serialise the nodes; it is to decide what "both wrote" *means* for that key and encode it as the reducer — concatenate, union, sum, or keep the highest-scoring one. The corresponding design failure is over-reducing: making everything a list because that avoids the error. Then a key that should hold one current answer instead accumulates every draft ever produced, state grows through every loop iteration, and downstream nodes have to guess which element is live. ## Writing your own A custom reducer is a plain function you point the annotation at. Keep it total (it must handle the initial value — often `None` or an empty container), keep it pure, and keep it cheap, because it runs on every write to that key. Reducers that do I/O, or that raise on unexpected shapes, turn a state merge into a failure point in the middle of a superstep. ## Pitfalls Annotating the reducer on the wrong key, or forgetting `Annotated` entirely and just writing `list[str]`, produces a graph that runs fine until it loops and then silently loses history. Mutating the accumulated list in a node instead of returning a new one bypasses the reducer altogether. And a reducer only ever sees values a node *returned* — it cannot rescue a node that forgot to return its work.

  • Why does add_messages exist instead of just using operator.add on the message list?
    Because concatenation duplicates. Agent loops re-emit messages — a streamed assistant turn finalised, or a node rewriting an earlier message — and those updates carry the same id. add_messages merges by id, replacing a matching message in place and appending only new ones, and it understands RemoveMessage for deleting a turn. Plain concatenation would grow history with near-duplicates the model then reads back.
  • What does the error look like when a last-value key gets two writes in one step?
    LangGraph raises InvalidUpdateError saying the key can receive only one value per step and pointing you at using an annotated key. It is deliberately an error rather than a silent pick, because two nodes in the same superstep have no defined ordering, so choosing a winner would make the run non-reproducible.
  • Are there properties a reducer must have to be safe?
    It should be pure, total and order-insensitive. Pure because it runs inside the state merge; total because it must handle the channel's initial value, often empty or None; order-insensitive because when two nodes write in the same superstep you do not control which update the reducer sees first. Anything that reads a clock, does I/O, or subtracts will give run-to-run variation.
  • Is there a downside to making every list-typed key a reducer key?
    Yes. An accumulating key never shrinks, so a graph that loops grows its state every iteration — larger snapshots, larger prompts if that key is rendered, and downstream nodes guessing which element is current. Use a reducer where accumulation is the meaning, and leave last-value semantics on keys that hold "the current answer".

saying these in an interview costs you the question

  • Thinks the last writer silently wins by default
  • Uses operator.add for a chat message list
  • Adds reducers to every key to avoid errors
  • Believes reducers run on keys a node omitted
  • Thinks a reducer merges state objects, not one key

context