teachyou.ai academy
← All posts
LangGraph

LangGraph Reducers: Controlling How State Updates Merge

Pramod Dutta · Jun 17, 2026 · 19 min read

You build a LangGraph agent, run it, and half your data disappears. The search node found five results, the summarizer added three more, but your final state only shows three. Nothing crashed. No error, no warning, just silently vanished data. If this has happened to you, you have met the default behavior of LangGraph state updates: last write wins. Every node that returns a value for a key overwrites whatever was there before. The fix is a small but genuinely important concept called a reducer, and understanding LangGraph reducers is the difference between agents that accumulate knowledge correctly and agents that quietly eat their own work. In this guide we will cover what reducers are, how the Annotated type syntax works, why operator.add shows up in nearly every LangGraph example, how add_messages powers chat agents, and how to write custom reducers for real production needs like deduplication, dictionary merging, and capped histories.

What State Actually Is in LangGraph

Before reducers make sense, you need a precise mental model of LangGraph state. A LangGraph graph is built around a shared state object, usually defined as a TypedDict, a Pydantic model, or a dataclass. Every node in the graph receives the current state as input and returns a partial update as output. The key word is partial. A node does not return the whole new state. It returns a dictionary containing only the keys it wants to change.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class AgentState(TypedDict):
    query: str
    documents: list[str]
    answer: str

def retrieve(state: AgentState) -> dict:
    # Returns a PARTIAL update, not the full state
    return {"documents": ["doc about pricing", "doc about refunds"]}

def generate(state: AgentState) -> dict:
    context = " ".join(state["documents"])
    return {"answer": f"Based on {len(state['documents'])} docs..."}

When a node returns {"documents": [...]}, LangGraph has to answer a question: how should this new value combine with the value already in the state channel? That combination step is exactly what a reducer defines. Each key in your state schema is a channel, and each channel has its own merge policy. If you do not specify one, LangGraph uses the simplest possible policy: replace the old value with the new one.

This design comes from the same family of ideas as reducers in Redux or the reduce function in functional programming. A reducer is a pure function that takes the existing accumulated value and an incoming update, and returns the next value: conceptually reducer(current_value, new_value) -> merged_value. You never invoke the reducer yourself; you declare it once on the schema and the runtime applies it on every update, including updates that arrive simultaneously from parallel branches.

This channel-based model explains a lot of otherwise mysterious LangGraph behavior: why nodes should return new values rather than mutating state in place, why two parallel nodes writing the same key can either crash or merge cleanly depending on one line of type annotation, and how checkpointing works, because what gets persisted at each superstep is the reduced state of every channel.

The Default Behavior: Last Write Wins

Let us make the failure mode concrete, because seeing it once will save you hours of debugging later. Here is a graph where two nodes both contribute documents, running one after the other, with no reducer declared.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    documents: list[str]

def web_search(state: State) -> dict:
    return {"documents": ["web result 1", "web result 2"]}

def db_search(state: State) -> dict:
    return {"documents": ["db result 1"]}

builder = StateGraph(State)
builder.add_node("web_search", web_search)
builder.add_node("db_search", db_search)
builder.add_edge(START, "web_search")
builder.add_edge("web_search", "db_search")
builder.add_edge("db_search", END)
graph = builder.compile()

result = graph.invoke({"documents": []})
print(result["documents"])
# ['db result 1']  <- the web results are gone

The web search results are simply gone. The db_search node returned a value for documents, and with no reducer declared, LangGraph applied the default policy: overwrite. The channel now holds only what the last writer put there. In a sequential graph this produces silent data loss. You could work around it by having each node manually read the old list and append to it, and many beginners do exactly that, but it is fragile, verbose, and it completely falls apart the moment you introduce parallelism.

The parallel case is where LangGraph stops being polite. If you fan out to web_search and db_search at the same time, both nodes finish in the same superstep and both try to write documents. LangGraph now has two competing updates for one channel and no rule for combining them. Rather than pick a winner arbitrarily, it raises an InvalidUpdateError telling you that the channel received multiple values in one step. This is a deliberate design choice: silent arbitrary ordering in concurrent systems is a bug factory, so LangGraph forces you to declare your merge intent explicitly. That declaration is the reducer.

So the default is not wrong, it is just a default. For keys like query or answer where the newest value genuinely should replace the old one, overwrite is exactly what you want. Reducers exist for the other keys, the ones that accumulate.

Annotated Types: The Syntax That Attaches a Reducer

LangGraph uses Python's typing.Annotated to attach a reducer function to a state key. Annotated is a standard library feature that lets you bolt arbitrary metadata onto a type hint. The first argument is the real type, and everything after it is metadata that frameworks can read. LangGraph reads the second argument and treats it as the reducer for that channel.

from typing import Annotated, TypedDict
import operator

class State(TypedDict):
    # Default channel: every write overwrites
    query: str

    # Reduced channel: every write is combined with the
    # existing value using operator.add (list concatenation)
    documents: Annotated[list[str], operator.add]

That single annotation changes the semantics of the documents channel completely. Now when db_search returns {"documents": ["db result 1"]}, LangGraph does not overwrite. It computes existing_documents + ["db result 1"] and stores the result. Rerun the sequential example from the previous section with this schema and you get all three documents. Run the parallel version and instead of an InvalidUpdateError, LangGraph reduces the concurrent updates one after the other into the channel, and you still get all three documents.

A few practical rules about this syntax are worth internalizing early. First, the reducer must be a callable that accepts two arguments, the current value and the update, and returns the merged value. operator.add fits because for lists it is concatenation. Second, the reducer is declared per key, so you can freely mix overwrite channels and reduced channels in one schema, and in real graphs you almost always do. Third, nodes still return plain updates. The node code does not know or care whether a reducer exists; it just returns {"documents": [new_item]} and the runtime handles the merge. This keeps nodes pure and testable in isolation. Fourth, because the update flows through the reducer, a node that wants to add one item must wrap it in a list. Returning {"documents": "a string"} on an operator.add list channel will concatenate string characters into your list or blow up, depending on types, which is a classic beginner mistake.

If you prefer Pydantic models over TypedDict, the same Annotated syntax works on model fields, and you get input validation as a bonus. The reducer mechanism is identical in both cases.

operator.add: The Workhorse Reducer

operator.add is the reducer you will see in ninety percent of LangGraph tutorials, and it deserves a section of its own because its behavior depends entirely on the type it operates on. operator.add(a, b) is just a + b. For lists, that means concatenation, which gives you append-only accumulation. For integers and floats, it means arithmetic addition, which gives you counters and running totals. For strings, it means concatenation, which is occasionally useful for building up a transcript or log.

Here is a realistic example that uses operator.add in two different ways in the same state: once to accumulate results and once to count LLM calls for budget tracking.

from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END

class ResearchState(TypedDict):
    topic: str
    findings: Annotated[list[str], operator.add]
    llm_calls: Annotated[int, operator.add]

def search_papers(state: ResearchState) -> dict:
    return {
        "findings": [f"Paper insight on {state['topic']}"],
        "llm_calls": 1,
    }

def search_blogs(state: ResearchState) -> dict:
    return {
        "findings": [f"Blog insight on {state['topic']}"],
        "llm_calls": 1,
    }

builder = StateGraph(ResearchState)
builder.add_node("search_papers", search_papers)
builder.add_node("search_blogs", search_blogs)
# Fan out: both run in parallel in the same superstep
builder.add_edge(START, "search_papers")
builder.add_edge(START, "search_blogs")
builder.add_edge("search_papers", END)
builder.add_edge("search_blogs", END)
graph = builder.compile()

result = graph.invoke({"topic": "rag evaluation", "findings": [], "llm_calls": 0})
print(result["findings"])   # both insights present
print(result["llm_calls"])  # 2

Notice what the integer channel gives you. Each node reports its own cost as a delta, {"llm_calls": 1}, and the reducer turns deltas into a total. This delta-based thinking is the idiomatic way to use reducers: nodes report what changed, not the new absolute value. It makes nodes composable, order-independent, and safe under parallelism.

There is one important caveat with operator.add on lists: it never deduplicates and it never bounds the list. Every loop iteration in a cyclic graph appends more items. An agent that loops ten times through a search node with an operator.add channel accumulates ten batches of results, including duplicates, and all of it gets serialized into your checkpointer on every superstep. For short-lived linear graphs this is fine. For long-running agents with cycles, unbounded operator.add channels are a memory and storage leak wearing a type annotation. When you need dedup, caps, or smarter merging, you graduate to custom reducers, which we cover shortly. You may also see from operator import add used bare, or Annotated[list, add]; it is the same thing.

add_messages: The Reducer Behind Every Chat Agent

If you have used LangGraph's prebuilt agents or the MessagesState class, you have been using a reducer all along without necessarily noticing. Conversation history is the canonical accumulating channel, but plain operator.add is not good enough for messages, because chat histories need more than blind appending. They need the ability to update an existing message in place, which is essential for streaming, and the ability to delete messages, which is essential for trimming context windows. LangGraph ships a purpose-built reducer called add_messages that handles all of this.

from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage, HumanMessage, AIMessage
from langgraph.graph.message import add_messages

class ChatState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

# This is essentially what langgraph.graph.MessagesState gives you
# out of the box, so you rarely type it yourself.

The behavior of add_messages follows one rule with two outcomes. Every message has an ID. When an update arrives, the reducer checks each incoming message ID against the existing list. If the ID is new, the message is appended, preserving conversation order. If the ID already exists, the incoming message replaces the existing one instead of being appended. That upsert semantic is what lets a streaming node emit progressively longer versions of the same AI message without duplicating it in history, and what lets you correct or edit a message after the fact by writing a new message with the same ID.

Deletion works through a special message type. If a node returns a RemoveMessage with a given ID, add_messages deletes the matching message from the list rather than appending anything. This is how context-window trimming is idiomatically implemented in LangGraph:

from langchain_core.messages import RemoveMessage

def trim_history(state: ChatState) -> dict:
    # Keep only the last 6 messages; delete the rest by ID
    stale = state["messages"][:-6]
    return {"messages": [RemoveMessage(id=m.id) for m in stale]}

The node did not slice the list and write it back. It emitted deletion instructions and let the reducer apply them. This is reducer thinking in its purest form: the node describes the change, the reducer enforces the policy. The add_messages reducer also coerces convenient shorthand formats, such as plain dicts like {"role": "user", "content": "hi"}, into proper message objects, which is why quick demos can pass raw dicts into messages and everything still works. Whenever you build anything conversational in LangGraph, reach for MessagesState or add_messages first; reimplementing message merging by hand is a rite of passage nobody actually needs.

Writing Custom Reducers

A custom reducer is just a Python function with the signature def reducer(current, update) that returns the merged value. Once you internalize that, a whole class of state-management problems becomes one small pure function each. Let us build three reducers that come up constantly in production agents: deduplicated accumulation, dictionary merging, and a capped list.

from typing import Annotated, TypedDict

def dedup_extend(current: list[str], update: list[str]) -> list[str]:
    """Append only items we have not seen, preserving order."""
    seen = set(current)
    merged = list(current)
    for item in update:
        if item not in seen:
            merged.append(item)
            seen.add(item)
    return merged

def merge_dicts(current: dict, update: dict) -> dict:
    """Shallow-merge metadata; new keys win, old keys survive."""
    return {**current, **update}

def keep_last_20(current: list, update: list) -> list:
    """Accumulate but never retain more than 20 entries."""
    return (current + update)[-20:]

class PipelineState(TypedDict):
    sources: Annotated[list[str], dedup_extend]
    metadata: Annotated[dict, merge_dicts]
    events: Annotated[list, keep_last_20]

Each of these solves a real failure mode. dedup_extend fixes the looping-agent problem where the same URL gets scraped and appended on every iteration. merge_dicts lets parallel nodes each contribute their own metadata keys without clobbering each other, which is impossible with the default overwrite channel. keep_last_20 puts a hard ceiling on state growth so your checkpoints do not balloon over a long-running session.

There are three rules that keep custom reducers correct. First, keep them pure: no I/O, no LLM calls, no reading globals, no randomness. The reducer may be called during normal execution, during replays from a checkpoint, and when concurrent branch updates are being folded in, so it must be deterministic and side-effect free. Second, do not mutate the current argument in place; build and return a new value. In-place mutation can corrupt checkpointed state in subtle ways that only show up when you resume a thread. Third, handle the empty case. On the very first update, current will be the channel's default value, typically an empty list or empty dict for these types, so make sure your function behaves sensibly when current is empty, and defensively handle None if your schema allows optional values.

One more subtlety: reducers define merge policy, not validation. If you need to validate incoming values, do it in the node or use a Pydantic state schema. A reducer that raises exceptions on bad input turns a data problem into a graph-execution failure at a confusing distance from the offending node.

Reducers and Parallel Execution: Fan-Out Without Collisions

Parallelism is where reducers stop being a convenience and become mandatory. LangGraph executes in supersteps: all nodes whose inputs are ready run together, and their state updates are applied at the end of the step. When you fan out from one node to several branches, every branch runs in the same superstep, and every branch's updates converge on the same state. Two writers, one channel, one step. Without a reducer that is an InvalidUpdateError. With a reducer, it is a merge.

This matters most in the map-reduce pattern with the Send API, where you dynamically spawn one worker per item and gather results. Here is a compact version, the shape of every parallel research or document-processing agent you will build.

from typing import Annotated, TypedDict
import operator
from langgraph.graph import StateGraph, START, END
from langgraph.types import Send

class MapState(TypedDict):
    urls: list[str]
    summaries: Annotated[list[str], operator.add]

class WorkerState(TypedDict):
    url: str

def fan_out(state: MapState):
    # One worker per URL, all in the same superstep
    return [Send("summarize", {"url": u}) for u in state["urls"]]

def summarize(state: WorkerState) -> dict:
    return {"summaries": [f"summary of {state['url']}"]}

builder = StateGraph(MapState)
builder.add_node("summarize", summarize)
builder.add_conditional_edges(START, fan_out, ["summarize"])
builder.add_edge("summarize", END)
graph = builder.compile()

out = graph.invoke({"urls": ["a.com", "b.com", "c.com"], "summaries": []})
print(len(out["summaries"]))  # 3, regardless of completion order

Ten workers, one summaries channel, zero collisions, because operator.add tells the runtime exactly how to fold ten concurrent updates into one list. Remove the annotation and this graph does not degrade gracefully; it refuses to run the moment two workers finish in the same step.

One thing reducers deliberately do not give you is ordering guarantees between parallel branches. The merged list will contain every branch's contribution, but the relative order of updates from concurrent branches should be treated as unspecified. If downstream logic depends on order, sort explicitly, or have each worker tag its output with an index or source name and reorder in the gather node. A common professional pattern is to accumulate Annotated[list[dict], operator.add] where each dict carries {"source": ..., "payload": ...}, keeping merges trivial while preserving enough structure to reconstruct any ordering you need. Design your reducers assuming updates arrive in arbitrary order, because under parallelism, they do.

Common Pitfalls and How to Debug Them

Reducer bugs are rarely loud. They are silent state corruption that surfaces three nodes downstream, so knowing the standard failure patterns saves real debugging time.

  • Forgetting the reducer entirely. The symptom is missing data with no error in sequential graphs, or InvalidUpdateError the first time branches run in parallel. The fix is auditing your schema: any key that more than one node writes, or that any node writes more than once across a loop, needs an explicit reducer.
  • Returning a bare item instead of a list. On an operator.add list channel, {"findings": "some text"} will either raise a type error or, worse for strings, concatenate item-by-item in ways you did not intend. Always wrap single items: {"findings": ["some text"]}.
  • Mutating state inside a node. Doing state["documents"].append(x) and then returning {} sometimes appears to work, but it bypasses the reducer, breaks under checkpoint replay, and behaves unpredictably with parallel branches. Nodes must communicate through returned updates only.
  • Unbounded accumulation in cyclic graphs. A looping agent with operator.add channels grows state on every iteration, and every superstep serializes the whole thing to your checkpointer. Symptoms are slowly increasing latency and ballooning checkpoint sizes. Fix with capped or deduplicating reducers, or a periodic cleanup node.
  • Reducers with side effects. A reducer that logs to a database or calls an API will fire during replays and time travel, duplicating those effects. Keep reducers pure and put side effects in nodes.
  • Resetting a reduced channel. You cannot clear an operator.add list by returning {"findings": []}, because concatenating an empty list is a no-op. If a channel must be resettable, write a custom reducer that recognizes a sentinel value, or model the reset as a separate overwrite-channel flag that downstream nodes respect.

For debugging, two techniques cover most cases. First, stream state deltas while the graph runs: iterate graph.stream(inputs, stream_mode="values") and print the channel you care about after each superstep, which shows you exactly which step lost or duplicated data. Second, unit test reducers directly, since they are pure functions: assert dedup_extend(["a"], ["a", "b"]) == ["a", "b"] is a one-line test that locks in merge behavior forever. Testing merge policy in isolation is dramatically easier than divining it from full-graph runs.

Choosing the Right Reducer: A Practical Decision Guide

After enough LangGraph projects, reducer selection becomes reflexive, but here is the decision process laid out explicitly for each key in your state schema.

  1. Ask who writes this key. If exactly one node writes it, exactly once, per run, the default overwrite channel is correct and a reducer would only add confusion. Keys like query, final_answer, or route_decision usually live here.
  2. Ask whether writes should accumulate. If multiple nodes contribute, or one node contributes repeatedly across loop iterations, and every contribution matters, you want accumulation: operator.add for lists and counters, add_messages for anything conversational.
  3. Ask whether duplicates are possible and harmful. Looping agents and overlapping search tools produce duplicates constantly. If duplicates poison downstream prompts or waste tokens, use a deduplicating custom reducer keyed on the item itself or on a stable ID field.
  4. Ask whether the channel can grow without bound. If the graph has cycles or long-running threads with a checkpointer, put a ceiling on accumulating channels with a capped reducer, or add a trimming node that emits deletions the reducer understands.
  5. Ask whether parallel branches write disjoint pieces of one structure. That is the dictionary-merge case: give each branch its own key inside a dict channel with a merge_dicts reducer, and no branch can clobber another.
  6. Ask whether you ever need in-place updates or deletions. If yes, plain concatenation cannot express them. Follow the add_messages design: give items stable IDs and write a reducer that upserts by ID and honors removal sentinels.

Two closing pieces of design advice. Keep your state schema small and intentional; every key is a contract between nodes, and reducers make those contracts explicit, so a schema where every accumulating key visibly declares its merge policy is self-documenting architecture. And prefer boring reducers. operator.add, a dedup function, a dict merge, and a cap cover the overwhelming majority of real agents. If a reducer grows complex branching logic, that logic probably belongs in a node, where it can be observed, streamed, and debugged, not buried in the merge layer.

Where to Go From Here

Reducers look like a niche typing trick, but they sit at the center of everything LangGraph does well. They are why parallel fan-out is safe, why chat history survives streaming and editing, why checkpoint replay is deterministic, and why multi-agent teams can write to shared state without trampling each other. Once you start reading Annotated[list, operator.add] as "this channel accumulates" and a bare str as "this channel overwrites," LangGraph state schemas turn into precise architectural documents, and an entire category of silent data-loss bugs disappears from your agents. Start by auditing one existing graph: find every key written by more than one node, decide the merge policy on purpose, and encode it as a reducer.

If you want to go deeper with structured, hands-on practice, this article is part of the LangGraph Tutorial course on teachyou.ai, where Pramod Dutta and Ira Menon build up from state graphs and reducers to checkpointing, human-in-the-loop interrupts, streaming, and full multi-agent systems, with runnable code at every step. The reducer patterns you learned here, deduplication, capped channels, message management, and parallel map-reduce, all reappear there inside complete production-style projects, so you see not just how reducers work but where they earn their keep in real agent architectures.