When two parallel LangGraph branches write the same state key, what happens?
answer
- parallel branches share one superstep
- plain keys accept one write per step
- the schema is the concurrency contract
- annotate to accumulate
- return your delta, not the merged list
basics
~20 sIf the key has no reducer, LangGraph raises InvalidUpdateError — it refuses to pick a winner between two writes in one step. Annotate the key with a reducer, such as Annotated[list, operator.add], and both writes are combined instead.
solid answer
~50 sBranches that a router fans out to run in the **same superstep**, and their updates are applied together at the end of it. A plain state key is a last-value channel that accepts exactly one write per step, so two concurrent writes raise `InvalidUpdateError` — the message says it can receive only one value per step and points you at annotating the key. The fix is a reducer in the state schema: `Annotated[list[str], operator.add]` appends, `add_messages` merges message lists by id, and a custom two-argument function handles anything else. Note the shape this forces on branch code: each branch returns its own contribution (`{"findings": [x]}`), never the whole merged list, because the reducer does the merging. A node with static edges from both branches acts as the join — it runs once, after the parallel step completes, and sees the reduced value.
code
python · 37 linesimport operator
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
findings: Annotated[list[str], operator.add]
def search_web(state: State) -> dict:
return {"findings": ["web result"]}
def search_docs(state: State) -> dict:
return {"findings": ["doc result"]}
def summarize(state: State) -> dict:
return {"findings": [f"summary of {len(state['findings'])} findings"]}
def fan_out(state: State) -> list[str]:
return ["search_web", "search_docs"]
builder = StateGraph(State)
builder.add_node("search_web", search_web)
builder.add_node("search_docs", search_docs)
builder.add_node("summarize", summarize)
builder.add_conditional_edges(START, fan_out, ["search_web", "search_docs"])
builder.add_edge("search_web", "summarize")
builder.add_edge("search_docs", "summarize")
builder.add_edge("summarize", END)
graph = builder.compile()
print(graph.invoke({"findings": []}))go deeper
Know that parallel branches writing the same key raise InvalidUpdateError, and that annotating the key with a reducer such as Annotated[list, operator.add] is the fix.
Explain the mechanism: fan-out puts tasks in one superstep, plain keys are last-value channels that accept a single write per step, and the reducer folds multiple writes into one value.
Add the operational layer — branches must return deltas not merged lists, the join node runs once after the step, accumulation order follows completion, and a failed superstep discards its siblings' writes so branch nodes must be idempotent.
Treat the state schema as the graph's concurrency contract: which keys accumulate, which are single-owner, and how that review reads before anything runs is what keeps parallel graphs safe as teams add branches.
## Why the error exists at all LangGraph executes as a sequence of supersteps: a set of tasks runs, then their writes are applied to the state channels, then the next set is chosen. Fan-out — a router returning a list of node names, several static edges out of one node, or a batch of `Send` objects — puts multiple tasks in one superstep. When two of them return the same key, the framework has a genuine ambiguity, and it refuses to resolve it silently. Unannotated keys in a LangGraph state schema are **last-value** channels: each step supplies at most one value, which replaces the previous one. Two values in a step is not "the later one wins" — there is no meaningful later inside a step, since the tasks ran concurrently and their order is not part of the contract. So LangGraph raises `InvalidUpdateError`, and the message tells you to use an annotated key. This is a design choice worth defending in an interview: silent last-write-wins would make results depend on scheduling, and the bug would surface as occasional missing data rather than as an exception on the first run. ## The fix: reducers A reducer is attached in the state schema through `Annotated[T, fn]`, where `fn(current, update)` returns the new value. LangGraph then applies it once per write, folding all of a step's writes into the channel. Common choices: - `Annotated[list[str], operator.add]` — list concatenation, the workhorse for accumulating branch results. - `Annotated[list[AnyMessage], add_messages]` — LangGraph's message reducer, which appends but also replaces messages that share an id, so a node can amend an earlier message rather than duplicate it. - A custom function, for example one that merges dicts key-wise or takes a max, when concatenation is not the right algebra. The reducer changes the contract for node code, and this is where teams trip: with a reducer in place, a node must return **only its own contribution**. Returning `{"findings": state["findings"] + [new]}` from two parallel branches with `operator.add` duplicates everything the branches already saw. Return `{"findings": [new]}` and let the channel do the folding. ## Joins After a fan-out you normally want a single node to consume the combined result. Give it inbound edges from the branch nodes: it runs **once**, in the step after the branches complete, with the reduced value already in state — not once per inbound edge. That is the ordinary join. Two refinements matter in real graphs. First, ordering: reducer folding follows completion, so an accumulated list does not necessarily mirror the order the branches were declared or dispatched in; if the downstream node cares, carry an index and sort. Second, uneven branches: when one branch is a single node and another is a three-node chain, or when a conditional edge may skip a branch entirely, expressing "wait for everything" with plain edges gets fragile. LangGraph provides deferred nodes for this — `add_node("aggregate", aggregate, defer=True)` holds the node until no other tasks remain pending — which is the clean way to write a join that must observe all upstream work regardless of branch depth. ## Failure semantics inside a parallel step A superstep is the unit of commit. If any task in it raises, the step's writes are not applied and, with a checkpointer configured, the run resumes by re-executing that step — including the tasks that had already succeeded. Two consequences for how you write branch nodes: 1. **Make them idempotent.** A branch that posts to an external system, or that appends to an accumulator without a natural key, will double up when the step re-runs after a sibling's failure. 2. **Do not use the accumulator as a progress log.** Because a failed step commits nothing, partially completed work is invisible in state; if you need per-item progress, record it in the item's own payload or externally. ## Debugging checklist When `InvalidUpdateError` shows up, the useful questions in order: which key does it name; which two nodes were in that superstep (the router's return value, or the fan-out list, tells you); is the key supposed to accumulate (add a reducer) or is only one branch supposed to own it (fix the branch that shouldn't write it); and is a node returning the merged list instead of its own delta (the duplicate-results bug that appears once you add the reducer). The deeper point is that concurrency in LangGraph is expressed in the *state schema*, not in the node code. The reducer annotations are the concurrency contract for the graph, and reviewing them is how you tell, without running anything, which keys are safe to write from a fan-out.
- With a reducer in place, what should each parallel branch return?Only its own contribution. The reducer folds every write into the channel, so a branch returning {"findings": [new_item]} is correct while {"findings": state["findings"] + [new_item]} re-adds everything the branch already saw and duplicates results once two branches do it. This is the most common bug introduced right after adding operator.add to fix an InvalidUpdateError.
- How many times does a node with inbound edges from two parallel branches run?Once. It is scheduled in the superstep after both branches complete, and it reads the state with all their writes already reduced in. It does not run per inbound edge. When branches have unequal depth, or one may be skipped by a conditional edge, express the join explicitly with a deferred node — add_node(name, fn, defer=True) — which waits until no other tasks are pending.
- If one branch raises while its sibling succeeds, is the sibling's write kept?No. The superstep is the commit unit: a failure means none of that step's writes are applied, and a checkpointed run resumes by re-executing the whole step, successful tasks included. So branch nodes must be idempotent, and any external side effect inside one needs its own de-duplication key. It also means an accumulator key cannot be used as a partial-progress log.
saying these in an interview costs you the question
- Expecting last-write-wins between concurrent branch updates
- Adding a reducer but still returning the merged list from each branch
- Assuming the join node runs once per inbound branch
- Believing the accumulator preserves branch declaration order
- Thinking a sibling's successful write survives a failed superstep