Send: the PUSH task primitive for node fan-out
Responsibilities
Send is LangGraph's "deliver a task to a specific node" primitive, defined in libs/langgraph/langgraph/types.py (Send class:664). A Send(node="foo", arg={"k": v}) means "on the next step, invoke node foo with arg as input". The difference from ordinary node triggering is: ordinary nodes (PULL) are passively triggered by channel subscription relationships, while Send (PUSH) is the previous node actively declaring "I want foo to run once, with this specific arg".
It sits between conditional edges and the TASKS channel: add_conditional_edges(START, lambda s: [Send("gen", {"x": 1}), Send("gen", {"x": 2})]) writes the list returned by the conditional function into the TASKS channel (goto as sends:62). On the next round, prepare_next_tasks pulls them out of tasks_channel.get(), assembling each Send into an independent PregelExecutableTask (PUSH tasks:421).
Design motivation
- Natural expression of map-reduce: The classic scenario is "run the same node N times in parallel with different inputs".
Send("generate", {"subject": s})returns a list from a conditional edge; each Send is an independent call, Pregel executes them concurrently, and the reducer merges the N results back into the main state. No need to manually expand N edges in the graph. - PUSH / PULL dichotomy: Node triggering falls into two categories — PULL (passive, triggered by updates to subscribed channels) and PUSH (active, explicitly delivered by
Send).prepare_next_tasksconsumes PUSH tasks from theTASKSchannel first, then computes PULL tasks viatrigger_to_nodes(PUSH tasks:440). argdoes not have to be the main state:Send.argcan be any object and is not forced to conform to the graph's main state schema — the target node'sinput_schemadecides the shape ofarg, allowing "heterogeneous inputs to the same node", e.g.Send("review", ReviewContext(doc=...))vsSend("review", DefaultContext()).Sendis equivalent toCommand.goto:Command(goto=Send(...))goes through the sameTASKSchannel inmap_command(goto as sends:62). So conditional edges and node return values converge on the same PUSH task scheduling — a unified abstraction.- Each
Sendis an independent task: N elements in aSendlist become N independentPregelExecutableTasks, each with its own task_id and path. They can be retried and error-handled individually — one failure does not affect other concurrent Sends.
Key files
Send class:664— TheSend(node, arg, *, timeout=None)data class;__slots__ = ("node", "arg", "timeout").Send __init__:718— Constructor;timeoutgoes throughTimeoutPolicy.coerce.Send __hash__:739—hash((node, arg, timeout)), used as the dedup key for thefuturesdict.prepare_next_tasks:392— The main scheduling function; consumes PUSH tasks first, then computes PULL candidate nodes.PUSH tasks:440— Takes the Send sequence out of theTASKSchannel; each is passed toprepare_single_taskand assembled into aPregelExecutableTask.PULL tasks:470— PULL path; computes which nodes should run based ontrigger_to_nodes; does not consumeTASKS.goto as sends:62—map_commandwrites theSendfromCommand.gotointo theTASKSchannel, unifying the PUSH path.add_conditional_edges:969— Conditional edge entry; the conditional function returns aSendor a list ofSends.PregelExecutableTask:627— The executable form of a task; holdsname/input/proc/writes/triggers/pathand so on.PregelRunner.tick:176— Concurrently executes all PUSH + PULL tasks for this step; tasks delivered bySendare run here.
Data flow
Send is actually consumed at the top of prepare_next_tasks (prepare_next_tasks:392):
input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] = {}
checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", ""))
null_version = checkpoint_null_version(checkpoint)
tasks: list[PregelTask | PregelExecutableTask] = []
# Consume pending tasks
tasks_channel = cast(Topic[Send] | None, channels.get(TASKS))
if tasks_channel and tasks_channel.is_available():
for idx, _ in enumerate(tasks_channel.get()):
if task := prepare_single_task(
(PUSH, idx),
None,
checkpoint=checkpoint,
checkpoint_id_bytes=checkpoint_id_bytes,
checkpoint_null_version=null_version,
pending_writes=pending_writes,
processes=processes,
channels=channels,
managed=managed,
config=config,
step=step,
stop=stop,
for_execution=for_execution,
store=store,
checkpointer=checkpointer,
manager=manager,
input_cache=input_cache,
cache_policy=cache_policy,
retry_policy=retry_policy,
):
tasks.append(task)The TASKS channel is a Topic[Send] — a channel type that supports multiple writes and multiple reads (Topic:23). Each Send is pulled out of tasks_channel.get(); (PUSH, idx) is passed as the task_id prefix to prepare_single_task, which pulls the corresponding PregelNode config (proc / writers / retry_policy, etc.) from processes[send.node] and assembles send.arg as input into a PregelExecutableTask. Send.arg does not enter the main state channel — it is passed directly to the node proc as task.input. What the node function receives is this arg, not a full snapshot of the main graph state.
There are two paths for Send to enter the TASKS channel: (1) a conditional function configured by add_conditional_edges returns a list of Send, and attach_branch writes them into TASKS; (2) a node function returns Command(goto=Send(...)), and map_command in _io.py writes the Send into TASKS (goto as sends:62). Both paths converge — write into the TASKS channel, consume on the next round by prepare_next_tasks.
Boundaries and failure modes
Sendmust target a registered node: Whenprocesses[send.node]does not exist,prepare_single_taskreturnsNoneand the Send is silently dropped — no raise, but the task does not run. If you notice a Send not taking effect during debugging, first check whether the target node name was registered inadd_node.Send.argdoes not go through the state reducer:Send'sargis passed directly as node input, without going through the main state channel's reducer merge. If the target node expects to read the main state, it must obtain it inside the node function viaconfig[CONFIG_KEY_READ]or astate: Stateparameter —argis just "extra input".Sendlist dedup relies on hash:Send.__hash__uses the(node, arg, timeout)triple (Send __hash__:739). If two Sends to the same node have an unhashablearg(like a dict), it raisesTypeErrordirectly —Sendis used as a dict key in the dedup path of_call.- The same node can be triggered by both PULL and PUSH: If
Send("foo", arg)delivers tofoowhile one offoo's trigger channels is also updated,prepare_next_taskswill list both the PUSH task and the PULL task, andPregelRunner.tickwill run both concurrently — this is allowed, but be aware the node function may be called twice within the same step. Send.timeoutis per-task:Send(node, arg, timeout=10)only applies to this PUSH task and does not affect the target node's defaulttimeoutconfig (Send __init__:718).- The
TASKSchannel is aTopic, not aLastValue:Topicsupports accumulated multiple writes (Topic:23), so multiple nodes can all writeSends into it during the same superstep, and the next round consumes them all at once — this is the foundation of map-reduce.
Summary
Send is the carrier for PUSH tasks, complementing ordinary PULL nodes. Conditional edges and Command.goto converge on the same TASKS channel and are uniformly scheduled by prepare_next_tasks. For how tasks are run concurrently see /func/concurrency; for the conditional-edge-to-node relationship see libs/langgraph/langgraph/graph/_branch.py.
See official docs: LangGraph docs · README.