← Back to Guides
2

Series

Building Real-World Agentic AI Systems with LangGraph· Part 2

GuideFor: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

LangGraph Reducers Are a Concurrency Policy

Rename a node and the answer changes. The code that decides how concurrent writes merge lives inside a type annotation, appears on no diagram, and the docs decline to guarantee its order.

#langgraph#agent-state#reducers#concurrency#state-management#production-ai

Adding a second retriever to a LangGraph agent is a ten-line change. It crashes the graph on the first run, and the two-line fix everyone reaches for trades a loud failure for a quiet one whose cost depends on something nobody checks.

Atlas is the customer-support agent from Part 1. It answered questions from one knowledge base. Someone asked for a second source, so we added a search_tickets node next to search_kb, let both run in parallel, and let both append what they found to evidence: NotRequired[list[Evidence]].

code
langgraph.errors.InvalidUpdateError: At key 'evidence': Can receive only onevalue per step. Use an Annotated key to handle multiple values.For troubleshooting, visit:https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE

That error is LangGraph behaving well. Two nodes finished in the same step, both wrote evidence, and the runtime refused to guess which one you meant. It failed loudly, before anything reached a customer. LangChain's own agent framework has the shape that produces it. In deepagents, files carries a reducer and todos does not, and a user hit this exact error on todos with parallel subagents writing it. Maintainers closed that report as unreproducible, so treat it as a schema asymmetry you can go and read rather than as a confirmed incident - which is the point either way, since nothing in the type or the graph surfaces the difference between those two keys.

The InvalidUpdateError fix, and why it is a trap

Then you follow the link. The official troubleshooting page explains that concurrent updates create "uncertainty around how to update the internal state," and it prescribes a reducer:

code
from typing import Annotatedimport operatorevidence: Annotated[NotRequired[list[Evidence]], operator.add]

The crash stops. Both writes survive. That line is what the framework's own error page tells you to write, which is reason enough to expect it in most graphs that fan out.

The crash was the easy failure. It named the key, named the step, and linked the page that explains it. What replaces it is a graph that can answer the same billing question two different ways depending on which retriever somebody renamed last, with nothing in the logs connecting the rename to the answer.

You have just replaced a loud failure with a silent one. The state is no longer wrong in a way that raises. It is wrong in a way that depends on what you named your nodes.

The write relation: why LangGraph reducers are a concurrency policy

Part 1 made a specific claim about what a graph buys you. The edge set turns control flow into data. graph.get_graph().draw_mermaid() hands back a sound over-approximation of the transition relation: an upper bound on which node can run after which, reviewable on a Tuesday before anything runs. That artifact is the reason to prefer a specified runtime over an Accidental Runtime.

Here is the other half, and nobody ships an artifact for it.

A graph has a second relation. Call it the write relation: which node writes which state key, and by what policy concurrent writes to that key are merged. It is not a coinage, it is the obvious counterpart to the transition relation. What matters is that it has no draw_mermaid(). It appears on no diagram, in no edge list, and in no type checker's output.

Part 1's central guarantee shows the gap precisely. issue_refund has a guarded mandatory predecessor: one inbound edge, guarded by state, so every path to a refund passes the entitlement check. That property is real and it is visible in the edge set. It says nothing whatsoever about refund_cents. Two nodes can write that key, the merge policy decides which value survives, and no part of the guarantee Part 1 spent its length establishing extends to the number the refund is for.

The merge half of it lives in one place: the reducer. And a reducer is executable code that you pass as metadata inside a type annotation. Annotated[list, operator.add] is a function argument dressed as a type. It is skipped by every review surface that reads your graph, because none of them read annotations as behaviour.

So the thesis:

The reducer is not a merge helper. It is the concurrency-control policy for that state key. A reviewer, a diagram, and a type checker all miss it, and it is the only thing they miss that decides what the state value is when two nodes write at once. The ecosystem's default choice - operator.add, prescribed by LangGraph's own error page - has no order-independence property. So the value of your state becomes a function of the order writes are folded in when two writers land in the same superstep, and that order is derived from your node names.

The consensus this argues with is not subtle. Reducers are taught as plumbing. They are described as "how you accumulate results from parallel branches," which makes them sound like a detail of list handling. They are the thing standing between two concurrent writers and a correct answer, which is a different job with a different name in every other part of computing.

There is a second asymmetry underneath the first, and it is the reason this cannot be fixed by drawing a better diagram. Part 1's edge set gives you a sound over-approximation: it may show a transition that never happens, and it never misses one that can. That is the direction of error you want from a safety artifact, because an upper bound can justify a claim about what the system will never do. The write relation cannot be recovered that way. The merge policy is fully static, but the node-to-key mapping is not, as I show at the end. So the best available artifact is a coverage-bounded under-approximation. Everything it reports is real, and it misses any write on a path you never exercised. Those are opposite kinds of wrong. Control flow got the useful kind. State did not.

LangGraph reducer order: rename a node, change the answer

Here is Atlas with the parallel retrieval that caused the crash, now with the prescribed reducer. This builds directly on Part 1's state, which I have extended by a single key.

code
import operatorfrom typing import Annotated, Literal, NotRequired, TypedDictfrom langchain.messages import AnyMessagefrom langgraph.graph import StateGraph, START, ENDfrom langgraph.graph.message import add_messagesclass Evidence(TypedDict):    source: str    text: strclass AtlasState(TypedDict):    # Supplied by the caller at invoke() time. The model never writes this.    customer_id: str    messages: Annotated[list[AnyMessage], add_messages]    # Model-written or derived from a model-written field.    intent: NotRequired[Literal["refund", "question"]]    ticket_id: NotRequired[str | None]    refund_cents: NotRequired[int]    refund_allowed: NotRequired[bool]    receipt_id: NotRequired[str | None]    # New in Part 2. Two retrievers opened this article; a third is added below.    evidence: Annotated[NotRequired[list[Evidence]], operator.add]def make_retriever(source: str):    def node(state: AtlasState) -> dict:        hits = corpus.search(source, state["customer_id"], state["messages"][-1])        return {"evidence": [{"source": source, "text": h} for h in hits]}    return nodebuilder = StateGraph(AtlasState)builder.add_node("triage", triage)builder.add_node("search_kb", make_retriever("search_kb"))builder.add_node("search_tickets", make_retriever("search_tickets"))builder.add_node("search_docs", make_retriever("search_docs"))builder.add_node("answer", answer)builder.add_edge(START, "triage")for name in ("search_kb", "search_tickets", "search_docs"):    builder.add_edge("triage", name)    builder.add_edge(name, "answer")builder.add_edge("answer", END)

triage, answer, corpus, render_context, model, and SystemMessage carry over from Part 1 unchanged, as do the companion-repo stubs behind them. Three retrievers, one accumulator, one answer node that renders evidence into the prompt: fan-out, fan-in, one shared accumulator.

Run it and look at the order the evidence arrives in:

code
('search_kb', 'search_tickets', 'search_docs')  -> ['search_docs', 'search_kb', 'search_tickets']

Not the order I added the nodes. Not the order they finished. It is the node names, sorted.

Now do the most ordinary refactor there is. Someone points out that search_docs retrieves internal design papers rather than product documentation, so it should be called search_papers. One rename. No change to the function, the edges, the fan-out, or anything else:

code
('search_kb', 'search_tickets', 'search_papers')  -> ['search_kb', 'search_papers', 'search_tickets']

That evidence moved from first position to the middle. It is the same evidence, retrieved by the same code, from the same source.

Lost in the Middle is what makes that particular move the expensive one. Model attention to retrieved context is U-shaped: strongest at the start, second-strongest at the end, weakest in between. The rename took the design-paper hit off the top of the prompt and put it in the position the curve punishes hardest. Nobody who reviewed that commit was thinking about prompt position, because the commit was about a name.

One precondition, because it is load-bearing and belongs here rather than buried in a checklist. The sort applies within a single superstep. It reorders writers that are co-active - a fan-out from a shared predecessor, or a Send batch. Writers at different depths fold in control-flow order across supersteps, and renaming those changes nothing:

code
zzz_first -> aaa_second -> zzz_first -> aaa_second ...

So the defect needs two things at once: two writers to the same non-commutative key in one step, and something downstream that reads the order. That is narrower than "your agent is nondeterministic." It is also true of every parallel-retrieval graph I have seen.

Add order does not save you, and neither does the order you wrote the edges:

code
nodes ("alpha", "zulu")              -> ['alpha', 'zulu']nodes ("zulu", "alpha")              -> ['alpha', 'zulu']   # add order is irrelevantnodes ("verify_a", "analyze_b")      -> ['analyze_b', 'verify_a']nodes ("KBSearch", "search_docs")    -> ['KBSearch', 'search_docs']nodes ("2_retriever", "a_retriever") -> ['2_retriever', 'a_retriever']

The third line is the one that matters. verify_a runs the same function it ran before. Its edges are unchanged. It moved because a sorts before v.

The last two lines are why I will stop saying "alphabetical." The sort is codepoint-wise on the node name, so uppercase precedes lowercase and digits precede letters. KBSearch wins on a capital K. I will keep writing "by name" as shorthand, but if you are predicting the order for a real schema, sort it the way Python does rather than the way a dictionary does.

Every one of these graphs draws the identical Mermaid. Same node count, same edge count, same shape, same dotted and solid edges. The artifact Part 1 taught you to review shows nothing, because nothing it models changed.

That is the whole argument in one experiment. The rest of this is mechanism.

Order-Coupled State: naming the defect

The pattern deserves a name, because "the list came out in a different order" does not sound like a defect until it has a handle.

Order-Coupled State: state whose value is a function of the order its writes are folded in, where that order comes from the runtime's task-ordering rule rather than from anything semantic in the data.

In LangGraph 1.x that rule is the node name for edge-triggered tasks and the Send list index for pushed ones. The definition deliberately does not say so, because the sort key is an implementation detail and the defect would outlive a change to it.

The definition has two halves and both are load-bearing. The first says the value depends on fold order. The second says that order comes from the runtime rather than from your data. A node name is documentation. A Send list index is an artifact of however the list got built. Neither is a decision anyone made about correctness, and both are exactly the kind of thing a refactor changes without a second thought.

A non-commutative reducer makes a key an order-coupled hazard. Two more conditions promote a hazard to a defect: two writers have to be co-active in one superstep, and something downstream has to read the order. Keep those separate. Most schemas have several hazards and one or two real defects, and the audit at the end of this article is built to tell them apart.

Order-Coupled State is a property a key has, not a bug you can point at, which is why it survives code review. evidence has it. messages has it. customer_id does not, because it has no reducer and therefore cannot take a concurrent write at all - it raises instead. It can still be silently overwritten one superstep later, which is the trap near the end of this article. That order comes from control flow, which is at least something somebody decided.

Naming it is most of the work, and there is precedent for that claim. In 1995, Berenson and colleagues wrote A Critique of ANSI SQL Isolation Levels, whose contribution was largely to name the anomalies the standard had failed to describe. Write skew was happening in databases before that paper. What the paper changed was that engineers could now say "this is write skew" in a review, instead of describing a confusing symptom and moving on. Agent state is at the pre-1995 stage. The anomalies are shipping, and the vocabulary to reject them in a pull request does not exist yet. "The evidence list came out in a weird order" gets waved through. "This key is Order-Coupled State and three nodes write it" does not.

Why evidence order changes the model's answer

The reasonable objection at this point is that a list is a list. If all three retrievers contributed, the model sees all three, and order is cosmetic.

It is not, though I want to be careful about how far the evidence reaches.

Liu et al., Lost in the Middle (TACL 2024), measured how language models use long contexts. They found a U-shaped curve. Accuracy is highest when the relevant document sits at the beginning or the end of the input, and it degrades in between. The control is the part that matters here: the positional bias held whether the documents kept their original ranking or were randomly shuffled, so the bias is positional rather than content-driven. The same positional degradation shows up on the retrieval side, where an assembler concatenates in reranker order and nobody checks what that does to the prompt.

State its setting honestly, because it is not quite yours. That paper measures single-relevant-document question answering among ten to thirty distractors, on 2023-era models. Atlas is synthesising an answer from three partially relevant evidence lists. The U-shape transfers; the effect size does not, and nobody has published one for that case.

For "reordering changes outputs at small n," which is closer to what a three-item evidence list does, the better citations are newer. The Order Effect (arXiv:2502.04134) measures prompt sensitivity to input order directly. Set-LLM (arXiv:2505.15433) exists because "the model's response changes simply due to a reordering of the answer options" was worth building a permutation-invariant architecture to solve.

So: your parallel retrieval accumulates evidence into a list, the fold order decides the list order, and the list order decides the prompt order. Prompt order is a documented lever on model output.

Be precise about which half of that I demonstrated. I measured the order change - that is the console output above, and it is exact. I did not measure an answer change on Atlas. That step is imported from the literature, and for a three-item reorder in a synthesis task the honest statement is that the mechanism is well attested and the magnitude is unpublished. What a rename gives you is a silent, uncontrolled change to a variable the research says matters. No test catches it, because the evidence set is identical and every retriever ran.

This is the series thesis made concrete, and it is one of the failure modes that get misread as reasoning problems. It looks like a reasoning failure. The model gave a different answer to an unchanged question, so the model is the suspect. It is a state-management failure wearing a reasoning failure's clothes, and the actual cause is in a git mv.

Where LangGraph's merge order actually comes from

I dispatched research for this article believing the order depended on which branch finished first. That is wrong, and it is worth showing why, because the truth is more useful.

Writes are not applied when a node returns. LangGraph runs on supersteps, inherited from Pregel and Bulk Synchronous Parallel. Every node that is ready runs, all of them finish, and only then are the collected writes applied to channels. Inside apply_writes, before anything is folded:

code
# sort tasks on path, to ensure deterministic order for update applicationtasks = sorted(tasks, key=lambda t: task_path_str(t.path[:3]))

Edge-triggered tasks carry a path of (PULL, node_name). Send-created tasks carry (PUSH, idx), with the index zero-padded to ten digits so numeric order survives string sorting. Sorting those paths gives you: node name, codepoint-sorted, for parallel nodes; Send list position for fan-out; and because __pregel_pull sorts before __pregel_push, edge-triggered writes fold before pushed ones in a mixed step. One scoping note for anyone using the functional API: that (PUSH, idx) shape is Graph-API-Send-specific, and @task calls carry a longer path that sorts by parent first.

So the fold order is completely deterministic. Timing does not enter into it. A slow retriever and a fast one produce the same list.

There is a behaviour here, a design claim about it, and no specification of it. Those are three different things, and it is worth not confusing them, because I did at first.

The implementation sorts, with a comment saying why.

The framework's author defends that publicly. Nuno Campos, writing on the design of the LangGraph runtime, says updates are applied "in a deterministic order (this is what guarantees no data races)," and that the design "ensures that the execution order and latency of each node never influences the final output of the agent."

Read that carefully, because it is narrower than it first sounds and it is entirely true. It is a claim about timing. Node execution order and latency cannot change your output, and the sort is exactly what buys that. A slow retriever and a fast one give you the same list.

The reference documentation then declines to name which order you get:

"updates from a parallel superstep may not be ordered consistently. If you need a consistent, predetermined ordering of updates from a parallel superstep, you should write the outputs to a separate field in the state together with a value with which to order them."

These do not contradict each other. Timing-independence and "we are not telling you the order" are both true at once. That is not a disagreement, and calling it one would be the easy version of this argument.

It is worse than a disagreement. It is a behaviour with no specification: stable enough that you will build on it without noticing, and unsupported enough that nothing stops it changing. A contradiction gets noticed and filed. This does not.

I could not find the sort key stated on any documentation page. It is source-only. Treat it as an undocumented implementation detail: real, reliable in practice, and not something to depend on in writing.

The engineering position that falls out is not "LangGraph is nondeterministic." It is narrower and harder to argue with:

Your state value depends on an ordering that is real, load-bearing, derived from node names, invisible on every artifact, and formally unsupported.

LangGraph Send order: the case that genuinely varies run to run

There is one case where this stops being about refactors and becomes ordinary nondeterminism, and it is the one to worry about in production.

Send fan-out folds in list-index order. When the list is built by your code, that order is yours. When the list comes out of a model, it is not.

code
from langgraph.types import Senddef fan_out_subquestions(state: AtlasState) -> list[Send]:    # The model decides how many workers, and in what order.    plan = planner.with_structured_output(SubQuestions).invoke(state["messages"])    return [Send("research", {"question": q}) for q in plan.questions]

Same customer, same question, same retrievers, same evidence found. The planner returns its sub-questions in a different order this time, because that is what samplers do. The fold order follows the list, so the evidence is permuted and the prompt with it. Per the order-sensitivity work above - The Order Effect is the closer fit at this n - the answer can change:

code
plan ['refund window', 'shipping delay']  -> evidence ['refund window', 'shipping delay']plan ['shipping delay', 'refund window']  -> evidence ['shipping delay', 'refund window']

Identical set of writes. Different state value. Nothing changed in the input except the order a sampler emitted two strings in.

This is the honest version of "your agent is nondeterministic." Not the whole runtime - one narrow, specific, reproducible path, where model output order propagates into state value through a reducer nobody reviewed.

A reducer is a merge function without any of the laws

The vocabulary for this problem was settled fifteen years ago, in a different field, and LangGraph borrowed half of it.

Shapiro, Preguiça, Baquero and Zawirski defined Conflict-free Replicated Data Types (CRDTs) in 2011. The core result is about what a merge function must guarantee for replicas to converge no matter what order updates arrive in or how often they are duplicated. The merge must be commutative, associative, and idempotent. Those three properties are the price of not caring about order.

LangGraph asks you to supply that merge function and imposes none of those laws. Before leaning on the analogy, though, it is worth saying where it does not hold, because the disanalogy narrows the argument to the part that survives.

CRDT laws exist for replica convergence under unordered, duplicated, out-of-band delivery. A LangGraph superstep is none of that. It is a single process, one authoritative channel, and a deterministic left fold that applies each write exactly once. In that setting only commutativity is load-bearing. Associativity is structurally satisfied, because BinaryOperatorAggregate.update() folds left with fixed parenthesisation, so you cannot construct a case where non-associativity changes the result on that path. That is precisely why DeltaChannel, which does batch variably, has to state batching-invariance as an explicit obligation. Idempotence only starts to matter because node execution is at-least-once rather than exactly-once, which is a durability property and a Part 4 problem.

So the borrowed vocabulary earns one thing rather than three: it names commutativity as a merge obligation, and LangGraph never states it.

I am not the first person to point at operator.add here. Abdeali Azguards, writing on checkpoint collisions and write skew in LangGraph in May 2026, argues that the default operator.add duplicates state entries on retry loops and that reducers need CRDT semantics. That piece is about cross-thread checkpoint collisions and idempotence under retry, and it resolves them by pushing serialisation into Postgres. It does not address commutativity, the merge order among concurrent branch writes inside one superstep, or whether any of it is reviewable. Sajjad Khan's 2026 formalisation of concurrency anomalies in multi-agent language-model systems is the standing prior art on the general problem, and it models shared external stores rather than graph state channels. Both got to the neighbourhood first. What is left, and what this article is about, is narrower: the merge policy for a state key is itself an unreviewed concurrency-control decision, it is written as metadata inside a type annotation, and no artifact shows it.

I searched for it before writing this. The word "commutative" does not appear in the graph API documentation, the runtime documentation, binop.py, last_value.py, or message.py. BinaryOperatorAggregate, the channel behind every Annotated[T, fn] key you have ever written, has a one-line docstring that describes what it does and requires nothing:

code
"""Stores the result of applying a binary operator to the current value and each new value."""

LangGraph does state an obligation in two places, and both are about batch reducers. DeltaChannel requires reducers to be "deterministic and batching-invariant," and the Pregel runtime page says bulk reducers must be associative. Scope that correctly before you lean on it. DeltaChannel is beta and needs langgraph 1.2 or later. Batching-invariance is associativity across folds, which is a different property from commutativity rather than a weaker one - neither implies the other. It does not give you order independence. It gives you the freedom to batch.

Now check the reducers you actually use against the CRDT bar:

ReducerCommutativeAssociativeIdempotentOrder-independent
operator.add on listNoYesNoNo
add_messagesNoPartialWith caller-set idsNo
operator.or_ on setYesYesYesYes
operator.or_ on dictNoYesYesNo
max / min as a named wrapperYesYesYesYes

Two cells need their footnotes, because both are places I would have got this wrong.

add_messages is only partially associative. On the append and id-replace paths it holds. RemoveMessage breaks it, and REMOVE_ALL_MESSAGES breaks it hard. With A=[m1], B=[m2], C=[RemoveMessage(REMOVE_ALL_MESSAGES), m3], I get ['m3'] grouping left and ['m1', 'm3'] grouping right. Removing an id that is not present raises ValueError outright, so one grouping can raise where the other returns. Idempotence needs caller-assigned stable ids, because add_messages mints a fresh UUID for any message that arrives without one.

operator.or_ is the trap in this table. It is commutative on set and not commutative on dict, where | is right-wins. Commutativity is a property of the function and its operand type, never of the function alone. That distinction comes back to bite the audit tool at the end of this article.

operator.add is where the reasoning goes wrong, and it goes wrong for an understandable reason. Addition is the textbook example of a commutative operation. operator.add is named after it. But the operation applied to your state is not addition, it is list concatenation, and concatenation is associative without being commutative. [1] + [2] and [2] + [1] are different lists. The name carries an intuition that the behaviour does not honour, and nothing in the type, the annotation, or the error page that recommended it will correct you.

Test it rather than trusting either of us:

code
def commutes(fn, a, b) -> bool:    return fn(a, b) == fn(b, a)def test_evidence_reducer_is_order_coupled():    a, b = [{"source": "kb"}], [{"source": "tickets"}]    assert not commutes(operator.add, a, b)

Note the shape. The assertion documents the hazard and passes, so it belongs in a suite. A test named ..._is_commutative that asserts the opposite is a permanently red build and somebody will delete it.

The move worth making is to run commutes over every reducer in your schema and assert that the resulting hazard set matches an explicit allowlist. That test goes red the day somebody adds an order-coupled key, which is the day you want to hear about it.

Why add_messages order decides which message wins

add_messages is worse than it looks, because its dedupe path is order-sensitive too. When two parallel branches emit messages carrying the same id, the message from the later-sorting node wins the content. I ran this to be sure:

code
id collision across parallel branches (both write id='dup'):  survivors: [('dup', 'from verify')]

analyze and verify both wrote a message with id='dup'. verify won. Rename it to alpha and analyze wins instead.

What LangGraph's Mermaid diagram shows, and what it hides

mermaid
flowchart TD
    subgraph shown["What draw_mermaid() renders: the transition relation"]
        direction TB
        T1["triage"] --> R1["search_kb"]
        T1 --> R2["search_tickets"]
        T1 --> R3["search_docs"]
        R1 --> A1["answer"]
        R2 --> A1
        R3 --> A1
    end

    subgraph hidden["What no artifact renders: the write relation"]
        direction TB
        W1["search_kb<br/>writes evidence"] --> M["evidence<br/>reducer: operator.add<br/>fold order: node name"]
        W2["search_tickets<br/>writes evidence"] --> M
        W3["search_docs<br/>writes evidence"] --> M
        M --> V1["search_docs sorts first:<br/>top of the prompt"]
        M --> V2["rename to search_papers:<br/>middle of the prompt,<br/>where the U-curve dips"]
    end

    style T1 fill:#4A90E2,color:#FFFFFF
    style R1 fill:#98D8C8,color:#2C2C2A
    style R2 fill:#98D8C8,color:#2C2C2A
    style R3 fill:#98D8C8,color:#2C2C2A
    style A1 fill:#4A90E2,color:#FFFFFF
    style W1 fill:#98D8C8,color:#2C2C2A
    style W2 fill:#98D8C8,color:#2C2C2A
    style W3 fill:#98D8C8,color:#2C2C2A
    style M fill:#7B68EE,color:#FFFFFF
    style V1 fill:#6BCF7F,color:#2C2C2A
    style V2 fill:#E74C3C,color:#FFFFFF

The right graph is the same program. Nothing in the toolchain draws it. The purple node is a policy decision - which merge semantics this key gets - written as annotation metadata and never surfaced. The two nodes below it are the same graph, before and after a one-word rename, and the left panel cannot tell them apart.

How to fix order-coupled state in LangGraph

1. Use a commutative reducer

If the merge genuinely does not care about order, say so in code and the problem disappears:

code
def keep_max(a: float, b: float) -> float:    return max(a, b)# A set union does not care who wrote first.seen_sources: Annotated[NotRequired[set[str]], operator.or_]# Neither does a max - but wrap it, do not pass the builtin.highest_confidence: Annotated[NotRequired[float], keep_max]

The wrapper is not stylistic. Annotated[NotRequired[float], max] raises ValueError: no signature found for builtin <built-in function max> when you construct the graph, because _is_field_binop calls inspect.signature to check the reducer takes two positional arguments and the builtins do not expose one. min fails the same way. A named function also gives print_merge_policy something readable to report, and it survives pickling for a checkpointer.

One caveat on the set: a set survives LangGraph's default serialisation but not a JSON one, and it will not stream to a browser client as-is.

This is the only fix that removes Order-Coupled State rather than managing it. Reach for it first, and notice how often the accumulator you reached for a list to build did not actually need a sequence.

2. Carry the ordering key in the data

When order matters, make it depend on something meaningful and sort at the point of use. This is what the documentation recommends, and it is right:

code
class Evidence(TypedDict):    source: str    text: str    rank: float          # the ordering that matters; make_retriever must emit itdef answer(state: AtlasState) -> dict:    # Part 1's render_context, now handed evidence in a defined order    # instead of reading it off state in whatever order it merged.    evidence = sorted(state.get("evidence", []), key=lambda e: -e["rank"])    context = SystemMessage(content=render_context(state, evidence))    return {"messages": [model.invoke([context, *state["messages"]])]}

The reducer stays non-commutative. It no longer matters, because nothing downstream reads the fold order. The prompt order is now a function of relevance, which is a decision someone made, rather than of a node name, which is not.

It also gives you something to assert on. You can write ranks = [e["rank"] for e in evidence] and then assert ranks == sorted(ranks, reverse=True). There is no test you can write against "whatever order it merged in".

3. Give each writer its own key

When two nodes write the same key for no better reason than that they both produce output, stop sharing:

code
kb_hits: NotRequired[list[Evidence]]ticket_hits: NotRequired[list[Evidence]]doc_hits: NotRequired[list[Evidence]]

No reducer, no merge, no order. Each key has one writer node, which removes the fan-out and Command shapes by construction rather than by policy. It does not remove the self-retry shape, where one node writes its own key twice in a single superstep - that is langgraph issue #2336, and it is worth checking separately. The assembly happens in answer, in ordinary readable code, where a reviewer can see it.

This is the least fashionable option and often the correct one. Nobody enjoys a state schema with three near-identical keys in it, and I would still take it over a shared accumulator, because a write relation you can state in a sentence is a write relation somebody will read.

Four more LangGraph write-relation traps

Four more things sit in this blind spot. Each is small on its own and each has produced real issues.

Metadata order silently deletes your reducer. _is_field_binop inspects only the last item in Annotated[...]. Put anything non-callable after the reducer and the key silently becomes a plain last-value channel:

code
# The reducer is gone. No error at build time.evidence: Annotated[list[Evidence], operator.add, FieldDoc("retrieved hits")]

I confirmed this on langgraph 1.2.9: the channel comes back as LastValue, StateGraph() constructs without complaint, and the failure surfaces later as InvalidUpdateError, only under concurrency, only on the branch that happens to fan out. A wrong-arity reducer, by contrast, raises ValueError: Invalid reducer signature. Expected (a, b) -> c. Got (a, b, c=None) at construction. I checked both on 1.2.9. One of these two mistakes is caught at construction and the other is not.

Command does not suppress a static edge. The documentation states it plainly - "Command only adds dynamic edges, static edges defined with add_edge still execute" - and the consequence is a concurrent write you did not plan:

code
def router(state) -> Command[Literal["target"]]:    return Command(goto="target", update={"log": ["router"]})builder.add_edge(START, "router")builder.add_edge("router", "static_dest")   # still live
code
{'log': ['router', 'static_dest', 'target']}

router routed to target and static_dest ran anyway. If both destinations write the same key, you get an InvalidUpdateError from a branch you thought you had routed away from.

Send hides cardinality, not destination. Part 1 argued that Send preserves which nodes run and destroys how many. That holds. Here is the actual rendering of a map-reduce fan-out:

code
graph TD;	__start__([__start__]):::first	plan(plan)	research(research)	__end__([__end__]):::last	__start__ --> plan;	plan -.-> research;	research --> __end__;

One dotted edge. I ran that graph with three workers and with three hundred, and the picture is byte-identical - only the number of results changes. Since every worker's writes fold through the same reducer, in Send-list order, the drawn graph tells you neither how many values that key is about to receive nor in what order.

A shared-schema subgraph writes straight into the parent's channels. Pass a compiled subgraph to add_node() with overlapping keys and it reads and writes the parent's channels directly, so its writes go through the parent's reducers. The whole subgraph contributes as one task, sorted under the parent node's name rather than its internal node names, which means the sort rule composes across levels but does not see inside. When the schemas do not overlap you wrap it in a function that translates in and out, and only what that wrapper returns merges. The docs are explicit about the wrapper case and silent on the merge mechanics of the shared case, so I am reporting that one from the channel behaviour rather than from a documented contract.

A key with no reducer is not automatically safe. The hard error only fires for two writes in one superstep. Across supersteps a bare LastValue key takes silent last-write-wins, so two nodes at different depths both writing ticket_id will never raise and the second one simply wins. The loud failure that opens this article is the best case: the same mistake one superstep apart is silent. That is the same class of hazard as resuming into state that no longer holds - wrong quietly, and only visible if you went looking.

Deferred nodes are a mitigation, not a fix. add_node(..., defer=True) delays a fan-in node until pending tasks finish, which helps with uneven branch lengths. It does not make your reducer commutative, and it has open issues of its own: langgraph issue #6005 reports a deferred node executing twice when it sits downstream of another deferred node. Issue #4026, open since March 2025 with no maintainer response, shows a fan-in node running twice and the reducer faithfully recording the duplicate as duplicate data.

That last one is worth stating as a general property. A reducer records what happened. If a node runs twice, an accumulating reducer produces two entries, and the state is a truthful record of a bug. operator.add is not idempotent, so it cannot absorb a duplicate execution the way a set union or a keyed map would.

How to audit which node writes which state key

Part 1 ended with a printed upper bound on the transition relation. Part 2 cannot end with the equivalent for writes. Half of it is recoverable statically and half of it is not, and the split is where you have to put your testing effort.

The merge policy is fully recoverable statically. Reducers live in Annotated metadata, and both the standard library and LangGraph itself will hand them to you. This runs against a compiled graph and needs no execution:

code
from langgraph.channels.delta import DeltaChannel      # langgraph >= 1.2from langgraph.channels.last_value import LastValuedef print_merge_policy(compiled, samples) -> None:    """Static audit of every state key's concurrency policy.    samples maps a key to two example values of its declared type, used to    test commutativity directly instead of guessing from the reducer's name.    commutes() is the helper defined with the reducer test above.    """    for key, channel in compiled.builder.channels.items():        # DeltaChannel exposes .reducer; BinaryOperatorAggregate exposes .operator.        fn = getattr(channel, "operator", None) or getattr(channel, "reducer", None)        pair = samples.get(key)        if fn is None:            if isinstance(channel, LastValue):                policy, verdict = "none (last value)", "concurrent write is a hard error"            else:                # Topic, EphemeralValue, AnyValue, UntrackedValue, managed values.                policy, verdict = "-", "unknown channel type - inspect manually"        else:            policy = f"{fn.__module__}.{fn.__qualname__}"            if isinstance(channel, DeltaChannel):                verdict = "batch reducer - check commutativity by hand"            elif pair is None:                verdict = "reducer present - NO SAMPLE, commutativity unchecked"            elif commutes(fn, *pair):                verdict = "commutative on sampled values - order independent"            else:                verdict = "ORDER-COUPLED - value depends on fold order"        print(f"{key:<16} {type(channel).__name__:<24} {policy:<44} {verdict}")

The DeltaChannel branch is not defensive padding. A batch reducer has the signature reducer(state, writes: list) -> state, not (a, b) -> c, so a binary commutativity probe does not typecheck against it. Hand deepagents' _file_data_delta_reducer two plain dicts and it iterates one of them as if it were a list of write batches and calls .items() on a string. That is an AttributeError in the middle of your audit, on the exact key this branch exists to catch.

Two things in that function are the whole point of writing it carefully.

It tests commutativity rather than looking the reducer up in an allowlist of trusted names. My first version carried COMMUTATIVE = {"builtins.max", ..., "_operator.or_"} and it was wrong, for the reason the table above already gave: operator.or_ is commutative on set and right-wins on dict. A name-keyed allowlist would have stamped Annotated[dict[str, X], operator.or_] as order independent, which is the exact defect this article exists to find. Commutativity belongs to the function and its operand type, so the only honest check runs the function.

And it branches on channel class rather than on whether .operator happens to exist. DeltaChannel exposes .reducer, so an attribute probe returns None and the key gets reported as a hard-error channel - the safest-sounding verdict, on a key that has a reducer and does not raise. deepagents ships files as a DeltaChannel whose reducer is last-write-wins per filename, which makes it the most order-coupled key in that schema. A tool that silently defaults the channels it does not recognise to "safe" is the failure mode this article is about, and I wrote it that way on the first pass.

On Atlas, with two commutative keys added from the fixes above:

code
customer_id        LastValue                none (last value)      concurrent write is a hard errormessages           BinaryOperatorAggregate  ..._add_messages       ORDER-COUPLED - value depends on fold orderintent             LastValue                none (last value)      concurrent write is a hard errorticket_id          LastValue                none (last value)      concurrent write is a hard errorrefund_cents       LastValue                none (last value)      concurrent write is a hard errorrefund_allowed     LastValue                none (last value)      concurrent write is a hard errorreceipt_id         LastValue                none (last value)      concurrent write is a hard errorevidence           BinaryOperatorAggregate  _operator.add          ORDER-COUPLED - value depends on fold orderseen_sources       BinaryOperatorAggregate  _operator.or_          commutative on sampled valueshighest_confidence BinaryOperatorAggregate  __main__.keep_max      commutative on sampled values

The reducer and verdict columns are elided for width; the real run prints the full qualified name and the full verdict string.

builder.channels is not a documented surface either. I am using it for the same reason everyone ends up relying on the sort order - it is real and it is convenient - which is this article's own argument turned back on itself. get_type_hints(AtlasState, include_extras=True) is the standard-library route, it is what _get_channels uses internally, and it is the one to reach for if you want this artifact to outlive a minor version.

"Commutative on sampled values" is doing honest work in that phrasing, and the first time I ran this it caught me. I passed ([], []) as the sample for messages, two empty lists commute trivially, and the tool cheerfully reported messages as order independent. A sampled property test is only as good as its sample. Use values that actually collide - two non-empty lists, two messages with ids - and treat a key with no sample as unchecked rather than as safe, which is why the function reports it that way.

That table is a review artifact. It needs no run, and the channel and reducer columns are complete and deterministic - which is enough to catch the silent metadata trap, where a key you believe has a reducer shows up as LastValue. The commutativity column is only as complete as your samples, which is exactly why an unsampled key reports as unchecked rather than as safe. Put it in a test and assert on it.

Which node writes which key is not statically recoverable, and I will not pretend otherwise. StateNodeSpec carries input_schema, retry_policy, cache_policy, ends, defer and timeout. There is no output schema. Node functions return arbitrary dictionaries, return annotations are optional and read only for the Command[Literal[...]] control-flow case, and static analysis of return {...} breaks on conditional returns, dict spreads and helper functions. Anyone promising you a static write-relation printer is promising something the API does not support.

What you can do is observe it. stream_mode="updates" emits one chunk per node per step, and the documentation confirms that "multiple updates in the same step are streamed separately," so parallel nodes stay attributable:

code
from collections import defaultdictdef record_write_relation(compiled, inputs, config=None) -> dict[str, set[str]]:    """Runtime observation. Coverage-bounded: only sees paths this run took."""    writes: dict[str, set[str]] = defaultdict(set)    for ns, chunk in compiled.stream(inputs, config=config,                                     stream_mode="updates", subgraphs=True):        prefix = "/".join(part.split(":")[0] for part in ns)        for node, update in chunk.items():            if isinstance(update, dict):                writes[f"{prefix}/{node}" if prefix else node].update(update.keys())    return dict(writes)

subgraphs=True is not optional. Without it, a subgraph's internal nodes never appear and its writes surface under the parent wrapper node's name, so the tool reports attribution that is wrong rather than merely incomplete. Since create_agent embeds as a subgraph, leaving it off degrades exactly the shape you are most likely to have. Two smaller things: pass a config with a thread_id if the graph was compiled with a checkpointer, and know that an interrupt() ends the stream and hands back a partial relation.

Run it against a build with your side-effecting clients stubbed. This executes the graph, and Part 1's Atlas has a node that calls billing.refund(). The write relation is a property of what nodes return, so stubs do not distort it as long as they still exercise the same branches.

Union the results across your evaluation corpus and you have node to keys. Then cross it with the static table. For every key whose reducer is not commutative, list the nodes observed writing it. Then check whether two of them can be co-active in one superstep. That intersection is the review surface that does not currently exist anywhere.

This is the under-approximation I promised at the start, and its limit is not a detail to skip past. An upper bound on control flow can justify a safety claim: the diagram says a transition cannot happen, so it cannot. A lower bound on writes can never justify one. It reports hazards it has already seen, and stays silent about the branch your corpus never took. Do not let a clean write-relation report be read as a clean bill of health. It is a coverage statement about your tests, printed in the shape of a fact about your graph.

That is the honest summary of where LangGraph is today. Control flow became data and got an artifact. State merging is still code in an annotation, and getting a picture of it costs you a test corpus.

A five-question LangGraph state audit

Run this against any graph with more than one edge into a node.

  1. Which keys have a reducer? Print the merge policy table. Any key you believed had one and does not is a latent InvalidUpdateError.
  2. For each reducer, is it commutative? Run commutes(fn, a, b) from above, on values that actually collide. Every key that fails is an order-coupled hazard. Questions 3 and 4 are what decide whether the hazard is a defect.
  3. For each order-coupled key, does anything downstream read the order? A list rendered into a prompt does. A list you take len() of does not. If the order reaches the model, position bias makes it a correctness question.
  4. Can two nodes write that key in the same superstep? Four shapes qualify: fan-out from a shared predecessor, Send fan-out, a static edge surviving a Command(goto=...), and a retry inside a parallel branch (langgraph issue #2336).
  5. Is any fold order derived from model output? A Send list built from a structured model response makes the order vary run to run. This is the case that will not reproduce on your machine.

If questions 2, 3 and 4 are all "yes" for the same key, you have a live defect and no test currently covers it. If only 2 is yes, you have a hazard worth an allowlist entry so that somebody notices the day 3 or 4 becomes true.

What these fixes cost

The fixes are not free and pretending otherwise would be the same mistake as pretending the reducer is plumbing.

A commutative reducer costs you sequence: a set loses duplicates and ordering, and max collapses everything to one value.

Splitting into per-writer keys costs you a bigger schema and moves assembly into a node body. That is survivable, because assembly is a pure function over state rather than control flow, and it unit-tests the way a routing decision buried in a loop never did.

The ordering key is the expensive one, and I glossed it above. A rank field assumes your retrievers emit comparable scores. They do not. A vector index gives you cosine similarity in roughly 0 to 1, clustered high and hard to separate at the top. A keyword index gives you BM25, which is unbounded and scaled by corpus statistics that shift as the corpus does. A ticket API sorts by recency and gives you no score at all, so you invent one. Normalising those onto a single axis is a modelling decision, and picking it badly reintroduces the exact defect you were fixing - a prompt order that has nothing to do with relevance. You will get the normalisation wrong at least once. The difference is that a bad normalisation shows up in a diff and somebody can argue with it, which is more than a sorting before v ever gave you.

And the audit itself costs a corpus. The runtime half of the artifact is only as good as the paths you exercise, which means the write relation is one more thing your evaluation suite is now responsible for covering.

None of that changes the recommendation. It does mean you are choosing which cost to pay, and a reviewer should get to see which one you chose.

Specified or inherited, again

Part 1 asked one question about control flow: is the constraint a property of your system, or a hope about the model's behaviour. The demarcation was whether the constraint holds regardless of whether the model complies.

The same question applies to state, and most graphs currently fail it. The merge policy for a key holds regardless of the model, which is good. But it is not a decision anyone reviewed, it is not visible on any artifact, and its ordering is derived from identifiers chosen for readability. That is not a specified merge. It is an inherited one, arrived at by copying the line the error page suggested.

operator.add is a fine reducer. It is a terrible default, because it is the one the framework recommends at the exact moment you are least equipped to evaluate it - mid-crash, following a link, looking for the line that makes the red text stop.

Part 3 puts tools and middleware inside the agent loop, and both write state. Carry one question into it: for every key in your schema, who can write it, and what happens when two of them do? Right now you cannot answer that from any artifact. You can answer it from a table you print yourself, and that is enough to start.

References

Every result in this article was run on langgraph 1.2.9 with langchain-core 1.5.1 on 2026-07-27, matching the version matrix in Part 1. Source references are to main at that date. DeltaChannel requires langgraph 1.2 or later and is beta.


Agentic AI

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Books by Ranjan Kumar

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments