data/skills/n8n-subworkflows/SUBWORKFLOW_PATTERNS.md
Three n8n-specific patterns that don't fall out of the "should this be a sub-workflow?" decision tree: choosing mode: all vs each, splitting one capability into N+1 sub-workflows when its input contracts diverge, and using fire-and-forget to get real parallelism.
mode: all vs eachThe caller's Execute Workflow node has a mode that controls how items reach the sub-workflow.
mode | Sub-workflow runs | Items per run |
|---|---|---|
all (default) | once | all N items, flowing through nodes per-item as usual |
each | N times | exactly one item per run |
For a body that just processes items the ordinary way — map, filter, transform — the two are equivalent, because n8n nodes iterate per-item regardless of how many items arrived.
The split matters in exactly one situation: the body assumes it sees exactly one item. Three telltales:
mode: all it sees all N inputs and produces one aggregate across everyone. Under mode: each it runs N times and produces one aggregate per input — which is almost always what a per-customer / per-order body means.$json.customer_id, "send this one email") silently operates on only the first item, or mis-aggregates, when handed N at once.all.A sub-workflow Customer: build monthly summary whose body groups orders and emits one summary row.
mode: all on 50 customers' orders → the grouping node sees all orders at once and emits one summary blending all 50 customers. Wrong.mode: each → 50 runs, each handed one customer's orders, each emitting that customer's summary. Right.each over an internal Loop Over ItemsWhen you need per-item iteration, let the caller's mode: each do it rather than dropping a Loop Over Items node inside the sub-workflow. Reasons:
Reach for an internal loop only when iteration is genuinely part of the body's own job (e.g. paginating an API until exhausted), not when it's just "do this body once per input".
Principle: when one capability has multiple input paths whose contracts genuinely differ, split into one outer sub-workflow per contract, all calling a shared downstream sub-workflow for the common work.
The forcing function is structural in n8n: on a single Execute Workflow Trigger, passthrough (required for binary, and the only option when the sub-workflow takes no inputs) and Define Below (required for typed inputs that agents and structured callers can fill) are mutually exclusive. You can't have both on one trigger, so divergent contracts can't share one cleanly.
Common cases where contracts genuinely differ:
If the body opens with a top-level IF/Switch on which input shape arrived, that branch is the seam where two sub-workflows want to separate.
Faced with two divergent input shapes, the reflex is:
Why it's wrong:
$fromAI schema.For N divergent input contracts, build N+1 sub-workflows: one outer per contract, plus one shared downstream for the common work. Each outer does its input-specific prep — validation, fetching, normalization, hashing, extraction — and calls the shared core with a normalized shape. The shared core has a single typed input contract and knows nothing about which outer called it.
A "process this paper" capability that arrives either as an external ID or as a user-uploaded PDF:
Subworkflow: Process Paper from External ID
Trigger: Define Below { arxivId: string, source: string }
→ [validate ID, dedup, fetch metadata, download PDF, extract text]
→ [Execute Workflow → "Subworkflow: Summarize and Store Paper"]
with { arxivId, title, authors, body, source, ... }
Subworkflow: Process Paper from Uploaded PDF
Trigger: Passthrough (required — binary flows through)
→ [hash binary for a synthetic ID, dedup, extract text]
→ [Execute Workflow → "Subworkflow: Summarize and Store Paper"]
with { arxivId: "<synthetic>", title, body, source: "upload", ... }
Subworkflow: Summarize and Store Paper ← the shared core
Trigger: Define Below { arxivId, title, body, source, ... }
→ [LLM with structured output → Data Table insert → Return result]
The "pull" path (look up by ID) and the "push" path (data already in hand, here as binary) each get their own typed-or-passthrough trigger, and converge on one typed core. Add a third input shape later and you add a third outer — not a third branch.
The pattern generalizes: any time a capability has both a pull path (look up by ID) and a push path (caller already holds the data, including binary or a template), the split applies. For the binary-handling specifics, see n8n-binary-and-data; for wiring the typed outer as an agent tool, n8n-agents.
mode: each + options.waitForSubWorkflow: false is the only way to get genuinely concurrent sub-workflow execution in n8n. N input items dispatch N sub-workflow runs that execute in parallel (bounded by per-instance concurrency limits).
The catch: the caller doesn't know when — or whether — any of them finished. So this only works with a separate completion-tracking mechanism, typically a Data Table the sub-workflow writes to as it progresses (manage it with n8n_manage_datatable — see n8n-mcp-tools-expert).
mode: each and options.waitForSubWorkflow: false. The caller continues immediately.status: completed / error, plus output.timeout and exit.[Source: N items]
→ [Data Table: insert N rows, status = "inProgress"]
→ [Execute Workflow] # mode: each, waitForSubWorkflow: false
→ [Data Table: get rows for this run]
→ [IF all terminal?]
├── Yes → continue, aggregate
└── No → [IF under runtime cap?]
├── Yes → [Wait N s] → loop back to the Get
└── No → [update remaining rows → "timeout"] → continue
If a sub-workflow crashes without updating its row, the poll sees inProgress past the runtime cap and times it out — so a dead job can't hang the loop forever.
mode: each with waitForSubWorkflow: true instead.Pair the per-job error handling (the row's error status) with n8n-error-handling so a failed job is recorded, not just silently absent.