skip to content

How does LangGraph merge a node's return value into StateGraph state?

level: middleimportance: must knowfreq 76%

answer

  1. nodes return diffs, not whole state
  2. one channel per schema key
  3. omitted means unchanged
  4. default rule is last value wins
  5. in-place mutation is never applied

basics

~20 s

A node returns a partial dict, and LangGraph applies it key by key. Each schema key is a channel; by default the returned value overwrites the old one. Keys the node omits are untouched, and returning None or an empty dict updates nothing.

solid answer

~50 s

A node is called with the full current state and returns a **partial** update — a dict containing only the keys it changed. LangGraph does not replace the state with that dict. Instead, every key in the state schema is backed by its own channel, and each returned key is applied to its channel independently. The default channel is last-value: the returned value replaces whatever was there. Keys the node did not return keep their previous values, so omitting a key is how you say "leave it alone", not "clear it". Returning `None` or `{}` means no update at all — useful for pure side-effect nodes. Crucially, mutating the state object you were handed does **not** persist; only the returned dict is applied. Once every scheduled node in the superstep has returned, the merged state becomes the input to the next step.

code

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


class State(TypedDict):
    count: int
    name: str


def bump(state: State) -> dict:
    return {"count": state["count"] + 1}


def audit(state: State) -> None:
    print("observed", state)
    return None


builder = StateGraph(State)
builder.add_node("bump", bump)
builder.add_node("audit", audit)
builder.add_edge(START, "bump")
builder.add_edge("bump", "audit")
builder.add_edge("audit", END)

graph = builder.compile()
print(graph.invoke({"count": 0, "name": "kept"}))

go deeper

for a junior

Remember the two-line rule: a node gets the whole state and returns only the keys it changed. Never edit the state object you were handed.

for a middle

Explain the channel model — one channel per key, default last-value — and why returning None is a legitimate no-op for a side-effect node.

for a senior

Diagnose from the merge outward: when a value goes missing, identify which node returned which key, and recognise in-place mutation and forgotten returns as the usual causes.

for a principal

Own the design consequence: partial updates make nodes independently ownable, so state keys should have a single clear writer, and a key with many writers is a signal the graph's boundaries are drawn wrong.

## The update protocol LangGraph's execution model is borrowed from bulk-synchronous parallel processing: a run proceeds in *supersteps*. In each superstep, the scheduled nodes all run against the same snapshot of state; when they finish, their updates are applied; then the next set of nodes is scheduled. The unit that crosses that boundary is not a new state object — it is a set of per-key writes. So a node signature reads: take the whole state, return a small dict. ## Channels, not a dictionary It is tempting to picture state as one Python dict that nodes patch. The accurate picture is a *collection of channels*, one per key in your schema. Applying an update means routing each returned key to its channel and asking that channel how to combine the incoming value with what it holds. The default channel behaviour is **last value wins**: the update replaces the current value. That is why `return {"draft": new_text}` looks like plain assignment. But the indirection matters, because a key can be declared with a different combination rule — a reducer — and then the same `return` statement appends instead of overwrites. The node body does not change; the schema decides. ## Keys you don't return Omission means "unchanged". A node that returns `{"count": 2}` for a schema of `{count, name, history}` leaves `name` and `history` exactly as they were. There is no diffing, no reset to defaults, no error for the missing keys. This is what makes partial updates safe to write: a node only has to know about the slice of state it owns, which keeps nodes composable as the schema grows. The converse is that you cannot delete a key by omitting it. To blank a value you must return an explicit empty value for it — and with a reducer key, even that may be appended rather than applied, so "clearing" an accumulating channel usually needs a reducer that understands a sentinel. ## Returning None or an empty dict A node whose whole job is a side effect — emitting a metric, writing to an external system, logging — can return `None` or `{}`. Both mean no channel is written. Execution continues along the node's outgoing edges as normal. This is the idiomatic way to write an observer node without inventing a throwaway state key. ## Mutation is not an update This is the single most common bug. A node receives a state object and, being Python, can mutate it in place — `state["history"].append(item)` — then return `{}`. The append may appear to work inside that node, but nothing was written to a channel, so the change is not part of the applied update, is not visible to the merge, and is not captured in the state snapshot that gets persisted or streamed. Treat the input state as read-only and express every change through the return value. When a node needs to modify a list or dict, build a new value (or return the item and let a reducer combine it) rather than mutating the one you were given. ## Multiple writers in one superstep When only one node runs in a superstep, last-value semantics are indistinguishable from assignment. The model reveals itself when two nodes run in the *same* superstep and both return the same key. A last-value channel is defined to accept exactly one value per step, so the second write is not "later" — it is a conflict, and LangGraph raises `InvalidUpdateError` rather than silently picking a winner. Declaring the key with a reducer is what makes multiple writes well defined. ## What flows out of invoke After the final superstep, the accumulated channel values are the graph's result: `invoke` returns them as a dict shaped like the state schema. Because each node contributed only its own keys, the returned state is the accumulation of every partial update applied in order — which is exactly why an omitted key survives a long run untouched, and why a node that forgets to return its work silently produces a graph that runs green and changes nothing. ## Debugging the merge When a value is not what you expect, the useful question is "which node returned this key, and what channel rule applied?" Streaming updates per node makes each node's returned dict visible, which turns a mystery about final state into a specific claim about one node's return value.

  • How do you actually clear a state key rather than leave it unchanged?
    Return an explicit empty value for it — {"errors": []} or {"draft": ""} — because omission means "no update", never "reset". On a key that declares an accumulating reducer this does not work, since the reducer combines rather than replaces; there you either add a sentinel the reducer understands, or model the resettable value as its own last-value key.
  • What happens if a node returns a key that isn't in the state schema?
    There is no channel for it, so it is not a valid write and LangGraph rejects the update rather than inventing a key. Keep node return types honest — annotating the return as the state type, or a narrow TypedDict of just the keys the node owns, gets a type checker to catch the typo before the graph runs.
  • If a node calls an LLM and the call fails halfway, is a partial update applied?
    No. The update is the node's return value, so a node that raises returns nothing and writes nothing — state is unchanged for that node. That is a reason to keep a node to one retryable unit of work: a node that does three writes' worth of work either commits all of it or none of it.

saying these in an interview costs you the question

  • Thinks the returned dict replaces the whole state
  • Mutates the passed-in state and expects it to stick
  • Believes omitted keys are cleared or defaulted
  • Assumes the last writer always silently wins
  • Thinks edges carry data between nodes

context