Iteration and loop nodes both look like repetition at the UI level, but they are executed as child graphs. The important model is not “a node with a body” but “a parent graph that spawns a child GraphEngine and then re-joins the child result.” For configuration walkthroughs, see the official docs for Iteration
and Loop
; this page owns the execution semantics underneath them.
Mental Model
An iteration node is a map over a collection. IterationNode._create_graph_engine deep-copies the current VariablePool, adds the current index and item under the iteration node’s selector namespace, and hands that pool to a child GraphEngine. That child runs the iteration body as a real graph, emits its own graph events, and returns the chosen output_selector value back to the parent. The parent node then accumulates those per-item outputs and the child’s LLMUsage, and it annotates iteration-scoped events with ITERATION_ID and ITERATION_INDEX.
A loop node is a different shape. LoopNode repeats the same child graph for a fixed loop_count, evaluates break_conditions before the loop starts and again after each round, and threads loop-scoped variables through the run. Its events carry LOOP_ID and LOOP_INDEX, and the terminal reason is recorded as loop_break or loop_completed. The loop body is therefore not a collection map; it is bounded re-entry into the same body until a condition or count stops it.
Iteration vs Loop
The distinction matters because the two nodes answer different questions. Iteration asks, “How do these N inputs fan out to N child runs?” Loop asks, “How many times should this body repeat before a break condition ends it?” In graphon, iteration is shaped around iterator_selector, while loop is shaped around loop_count, break_conditions, and loop_variables.
That difference shows up in state handling. Iteration deep-copies the variable pool per item, so each child starts from an isolated snapshot plus its own index and item. Loop does not make that copy by default; LoopNode._create_graph_engine calls create_child_engine without a replacement pool, so the child builder falls back to the parent’s pool. The loop node compensates by clearing variables from the loop subgraph before each round so stale values do not leak forward.
Parallel Mode
Parallel iteration does not mean “the body becomes magically asynchronous.” In code, IterationNode uses a ThreadPoolExecutor, bounded by min(self.node_data.parallel_nums, len(iterator_list_value)). That means parallel_nums is a cap on how many item runs may be in flight at once; the default is 10. Items beyond that limit wait in the executor until a worker slot opens. The node still emits IterationNextEvent for each item as it submits work, so the run can show queued fan-out even before every child has finished.
flowchart LR
P[Parent GraphEngine] --> I[Iteration node]
I --> F[Bounded fan-out<br/>ThreadPoolExecutor]
F --> C1[Child GraphEngine 1]
F --> C2[Child GraphEngine 2]
F --> CN[Child GraphEngine N]
C1 --> W[(GraphEngine worker/queue machinery)]
C2 --> W
CN --> W
C1 --> J[Join and collect]
C2 --> J
CN --> JThe important point is the boundary between the outer fan-out and the child graph itself. The outer layer decides how many child runs are launched concurrently; the child GraphEngine then executes its own queue/worker path as described in 02-inside-the-graph-engine.md
. Parallel mode is therefore a bounded fan-out over child graphs, not a different execution substrate.
Ordering and Joining
Parallel execution keeps output order stable by indexing, not by completion time. IterationNode._execute_parallel_iterations pre-sizes the output list with None, stores each child result back at the original input index, and collects results from the futures as they finish. So the output collection preserves input order even if the child runs complete out of order. The join happens when all futures have been drained and _finalize_parallel_outputs has had a chance to compact the list if needed.
The same rule is visible in the event model. The iteration lifecycle has started, next, succeeded, and failed event shapes in node_events/iteration.py, and the loop lifecycle mirrors that in node_events/loop.py. Those event classes are the code’s record of “spawned,” “advanced,” “finished,” and “failed,” even when the actual body ran in child engines.
Iteration also has a small post-processing step on the collected outputs: if flatten_output is enabled and every non-None result is itself a list, the node flattens the nested lists into a single array. Otherwise, it leaves the output structure alone.
Error Semantics
The error mode is a trade-off between fail-fast behavior, positional fidelity, and result density.
terminatedstops the run on the first failure. In sequential mode, the node raisesIterationNodeError. In parallel mode, pending futures are cancelled and the iteration as a whole fails. This is the strictest option.continue-on-errorpreserves the shape of the result collection by leaving aNoneslot for the failed item. That keeps input positions aligned with output positions, which is useful when order matters more than density.remove-abnormal-outputprefers a dense result array. In sequential mode it skips appending the failed result; in parallel mode it leaves aNoneplaceholder until_finalize_parallel_outputsremoves failed slots at the end.
Loop error handling is similar in spirit but not identical in mechanics. A failed loop round still flows through loop-scoped failure events, and the terminal reason is recorded separately from the fixed-count completion path. The key distinction is that loop failure is about repeated re-entry into one body, while iteration failure is about one item in a collection.
Nesting and Limits
Child graphs can themselves contain child graphs, so nested iteration and loop nodes are literally engines inside engines. WorkflowEntry.__init__ enforces the top-level guard by rejecting a call_depth greater than dify_config.WORKFLOW_CALL_MAX_DEPTH. That guard is in the Dify layer, not in the node code, which makes nested repetition a workflow-level concern rather than a single-node quirk.
The Dify child-engine builder is intentionally narrow. build_child_engine creates a fresh GraphRuntimeState, preserves the parent execution context, and explicitly attaches LLMQuotaLayer. The parent entry path can add DebugLoggingLayer, ExecutionLimitsLayer, and ObservabilityLayer, but those are not added again inside build_child_engine, and the code does not introduce a pause or persistence layer there either. In other words, child engines are run with the child-safe pieces the builder chooses, not with a wholesale copy of every parent extension. For pause, resume, and persistence details, see 05-pause-resume-and-run-state.md
.
A small honesty note
The most surprising asymmetry in the code is that iteration and loop treat variable-pool isolation differently. Iteration deep-copies the pool per item; loop reuses the parent’s pool unless a caller explicitly provides a replacement. That is the clearest signal that the two nodes are similar in shape but not in state semantics.
Where to Look in the Code
dify/api/core/workflow/workflow_entry.py— child-engine construction, call-depth guard, and Dify-side layer selection.graphon/src/graphon/nodes/iteration/iteration_node.py— iteration fan-out, ordering, join behavior, output flattening, and error modes.graphon/src/graphon/nodes/loop/loop_node.py— loop repetition, break checks, loop-variable handling, and carried state.graphon/src/graphon/runtime/graph_runtime_state.py—create_child_engineand runtime-state delegation.graphon/src/graphon/graph_engine/graph_engine.pyandgraphon/src/graphon/graph_engine/worker_management/worker_pool.py— queue/worker execution architecture for child graphs.