In LangGraph, what does Send() in a routing function do that returning node names cannot?
answer
- dynamic width, decided from state
- each target gets its own input
- the map half of map-reduce
- reduce is just a reducer on a key
- results come back unordered
basics
~20 sSend lets one branch spawn a runtime-determined number of parallel invocations of the same node, each with its own private input state. Returning node names can only fan out to a fixed set of nodes, all sharing the graph state.
solid answer
~50 sA router returning a list of node names fans out to a **statically known** set of nodes, and every one of them is invoked with the shared graph state. `Send(node, arg)`, imported from `langgraph.types`, changes both halves: the router can return a list of `Send` objects built from state — one per document, per subtask, per row — so the width of the fan-out is decided at runtime; and each invocation receives `arg` as its state rather than the parent state, so the worker node's schema can be a small private TypedDict that knows nothing about the parent's. That is the map half of map-reduce. The reduce half is ordinary LangGraph: each worker's returned keys are merged into the parent state through the reducer annotated on those keys, so the accumulator key must be something like `Annotated[list[str], operator.add]` or the concurrent writes will conflict.
code
python · 31 linesimport operator
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.types import Send
class State(TypedDict):
subjects: list[str]
jokes: Annotated[list[str], operator.add]
class JokeState(TypedDict):
subject: str
def generate_joke(state: JokeState) -> dict:
return {"jokes": [f"a joke about {state['subject']}"]}
def fan_out(state: State) -> list[Send]:
return [Send("generate_joke", {"subject": s}) for s in state["subjects"]]
builder = StateGraph(State)
builder.add_node("generate_joke", generate_joke)
builder.add_conditional_edges(START, fan_out, ["generate_joke"])
builder.add_edge("generate_joke", END)
graph = builder.compile()
print(graph.invoke({"subjects": ["cats", "dogs"], "jokes": []}))go deeper
Know that Send comes from langgraph.types, is returned from a routing function as a list, and starts one run of a node per item with the payload you attach.
Explain the two differences from a plain list of node names: the fan-out width is computed from state at runtime, and each invocation gets the Send payload as its state rather than the parent state — so the worker needs its own small schema.
Show that reduce is not automatic: the accumulator key needs a reducer, results come back unordered, the whole fan-out is one superstep so a single failure re-runs everything, and fan-out width must be capped before it meets a provider rate limit.
Own the trade — Send makes cost and concurrency data-dependent, so decide deliberately when per-item graph tasks are worth their checkpoint and observability overhead versus batching the work inside a single node.
## The limitation Send removes Static fan-out — a router returning `["summarize", "classify"]` — has two constraints baked in. The destinations must be named at authoring time, and each destination receives the same object: the parent graph state. That is fine for "do these three fixed things in parallel". It cannot express "the model just produced seven subtasks; run the worker once per subtask", because seven is not known when the graph is written, and because each worker needs a *different* input. `Send` is LangGraph's answer. Imported from `langgraph.types`, it is constructed as `Send(node_name, arg)` and returned — usually many at a time, in a list — from a routing function registered with `add_conditional_edges`. Each `Send` becomes one independent task targeting that node. ## Private state per invocation The part that surprises people: the `arg` you pass is **the state the target node sees**, not a merge into the parent state. A worker written as `def worker(state: WorkerState)` where `WorkerState` is `{"subject": str}` will receive exactly `{"subject": "cats"}` if that is what the `Send` carried. It has no access to the parent's other keys unless you copy them into the payload. This is a real design property, not an accident. It means workers are small, independently testable functions with narrow contracts, and it means a hundred parallel invocations do not each carry a copy of a large parent state. The cost is explicitness: anything a worker needs — a model handle passed as config, a shared instruction string, a request id for tracing — must be put into the payload deliberately. ## Wiring it The router that emits `Send` objects is registered exactly like any other conditional edge, and the third argument should list the target node names so the graph can still be drawn: `builder.add_conditional_edges("planner", fan_out, ["worker"])`. A `Send`-emitting router can be attached to `START` as well, which is the usual shape when the fan-out is over the run's input. ## The reduce half LangGraph does not aggregate for you. Every worker invocation runs in the same superstep and returns a partial update; those updates are applied to the parent state through the channels' reducers. If the target key has no reducer, two workers writing it is a conflict and the run raises `InvalidUpdateError`. So map-reduce with `Send` always pairs with an accumulating key in the parent schema — `Annotated[list[Result], operator.add]` being the canonical form, or `add_messages` when the accumulator is a message list. Ordering is the second thing to get right: reducer-merged results arrive in completion order in the general case, so if the downstream step needs the original ordering, carry an index in each `Send` payload and have the worker return it alongside its result, then sort in the reduce node. Do not assume the accumulated list mirrors the input list. ## Joining after the fan-out After the workers, you usually want one node that reads the accumulated results. A static edge from the worker node to the aggregator gives you that: the aggregator runs once, after the whole fan-out step completes, not once per worker. When the aggregator also has other inbound paths that might finish at different times, LangGraph offers deferred execution — `add_node("aggregate", aggregate, defer=True)` — which holds the node back until no other tasks are pending, which is the reliable way to express "run this only when everything upstream is truly done". ## Operational consequences - **Concurrency is unbounded by default.** A router that emits one `Send` per retrieved document will happily emit 400 of them, each making a model call. Cap the fan-out width in the router — slice the list, or batch several items into one payload — rather than discovering the provider rate limit in production. - **Failure is per-superstep, not per-task.** All the `Send` tasks run in one step; if one raises, the step fails and its writes are not committed, so a resumed run re-executes the fan-out. Worker nodes should therefore be idempotent, and expensive external effects inside them deserve their own guard. - **Cost is now data-dependent.** The graph's token spend scales with a number that comes out of the model or the retriever, which is exactly the kind of thing that should be bounded and logged. ## When not to use it If the work is a fixed handful of distinct steps, static fan-out is clearer. If each item's work is a single model call with no branching, a batched call or a plain `asyncio.gather` inside one node is cheaper than materialising N graph tasks — you give up per-item checkpointing and per-item streaming, which is the trade to weigh. `Send` earns its complexity when the item count is dynamic and you want each item visible as its own unit of execution.
- How do the results of many Send-spawned invocations get back into the parent state?Through the ordinary channel reducers. Every invocation returns a partial update in the same superstep, and LangGraph merges them into the parent state using the reducer annotated on each key — typically Annotated[list, operator.add]. If the key has no reducer, two concurrent writes conflict and the run raises InvalidUpdateError. There is no separate collection API: reduce is just a well-chosen reducer plus, usually, one aggregator node downstream.
- Does the accumulated list preserve the order of the items you sent?Do not rely on it. Results are merged as tasks complete, so the accumulator generally reflects completion order rather than input order. If order matters, put an index into each Send payload, have the worker return it with its result, and sort in the aggregating node. That also makes partial results diagnosable when one item's output looks wrong.
- What bounds the number of parallel invocations a Send fan-out creates?Nothing by default — the router emits as many tasks as the list it is built from, so a retriever returning 400 documents becomes 400 concurrent node runs, each potentially a model call. Bound it in the router: slice to a maximum width, or pack several items into one payload so each worker handles a batch. Treat fan-out width as a capacity decision that belongs in code and in logs.
- If one worker in a Send fan-out raises, what happens to the others' results?They are discarded for that step. All the fan-out tasks belong to one superstep, and a superstep commits its writes atomically, so a failure means no partial results are persisted and a resumed run re-executes the whole fan-out. That is why worker nodes should be idempotent and why expensive or side-effecting work inside them needs its own de-duplication guard.
saying these in an interview costs you the question
- Thinking a Send target receives the full parent state
- Expecting LangGraph to aggregate results without a reducer
- Assuming results arrive in the order the Sends were created
- Emitting one Send per retrieved item with no cap on width
- Believing Send is needed for a fixed set of parallel nodes