<!-- TEMPORARY: change workflow prefix searching to tags when tag tools are added to mcp -->
n8n Sub-workflows
Sub-workflows are reusable functions. The
declares input parameters, the body does work, the last node returns output. Callers invoke it like any other node.
That framing opens up the function-shaped wins: encapsulation, reuse, testability, replaceability. It's the primary reuse mechanism in n8n, and unfortunately underused.
Without sub-workflows, the same logic gets duplicated across workflows. Bug fixes happen in multiple places, one gets missed, and "identical" copies drift.
Non-negotiables
- Search before you build. Before writing logic that handles a generic problem, check if a sub-workflow already exists. Use
search_workflows({ query: 'Subworkflow' })
, , etc. The MCP can't filter by tags, so naming is the discovery mechanism.
- uses "Define Below" with typed fields, not passthrough. Define Below is the only mode that lets agent tools () and structured callers pass values in. The single exception: passthrough is required when the sub-workflow specifically needs to receive binary, and that sub-workflow then can't be wired as an agent tool directly. See "Sub-workflow inputs and outputs" below.
Strong defaults
- Anything reusable becomes a sub-workflow. If a logical chunk could plausibly be needed elsewhere, extract it. Exception: trivial wrappers (one HTTP call, no logic) and tightly-coupled-to-this-caller chunks.
- Default to stateless for pure logic (input → output, no external state). For state-touching logic, build deliberately stateful sub-workflows that abstract the operation behind a clean contract (the ORM / repository pattern). What to avoid is accidental state: a "validate" sub-workflow that quietly writes to a log table.
- Verb-first prefix naming:
Subworkflow: Parse RFC2822 date
, Customer: hydrate from Stripe
, Tool: list available credentials
. The prefix is what matches on. See references/NAMING_AND_DISCOVERY.md
.
- Description carries keywords. Input/output shape + representative terms, so varied queries surface it.
- Split when input contracts genuinely differ (binary vs JSON, sync vs async, divergent auth schemes). Don't fit divergent contracts under one trigger via passthrough + internal branching. See
references/SUBWORKFLOW_PATTERNS.md
"Splitting by input shape".
Decision tree: should this be a sub-workflow?
About to write a chunk of logic?
├── Could this plausibly be needed in another workflow?
│ ├── Yes → extract to sub-workflow
│ └── No → keep inline
│
├── Is this chunk >5 nodes and conceptually one thing?
│ └── Probably yes-extract, even if reuse isn't certain. It's still better isolated.
│
├── Is this chunk dealing with a generic concern (auth, retry, parsing, formatting)?
│ └── Almost certainly extract. These are the canonical reusable sub-workflows.
│
└── Is this chunk doing one HTTP call with no logic around it?
└── Don't extract. Extra workflow boundary for nothing.
Stateless vs. stateful sub-workflows
Both are first-class. The choice is about intent and encapsulation.
Stateless
Takes input, returns output. No I/O outside the inputs/outputs. Default for pure logic.
Examples:
Subworkflow: Parse RFC2822 date
. Input: date string. Output: ISO date or error.
Subworkflow: Compute MRR from subscription
. Input: subscription object. Output: MRR number.
Subworkflow: Format invoice as HTML
. Input: invoice data. Output: HTML string.
When you need the logic again, call it without worrying about side effects firing.
Stateful (deliberate)
Reads or writes external state behind a clean input/output contract. Comparable to a repository pattern: the sub-workflow abstracts the state operation so callers think in domain terms, not implementation.
Examples:
- . Input: id. Output: customer object or
{ ok: false, error: 'not_found' }
. Reads the DB.
Customer: write billing record
. Input: record. Output: . Writes the DB.
- . Input: event. Output: . Writes to a logging store.
- . Input: channel, message. Output: . Calls Slack/SMTP.
The point of building these as sub-workflows:
- Callers think in domain terms (), not in storage (
SELECT * FROM customers ...
).
- Swap the underlying store/API behind it (Postgres → Supabase, native node → HTTP) without touching callers.
- Idempotency, retry, and validation become the sub-workflow's responsibility, centralized in one place.
What to avoid is accidental state: a sub-workflow named/described as pure that quietly writes to a log table. That ambushes callers who reasonably assumed it was safe to retry or compose. Either make the side effect part of the contract (rename, document, return its result) or move it out.
When to extract
The two main signals:
1. Conceptual coherence
When a chunk of nodes does one logical thing, even unreused, it's often worth extracting. Beyond reuse:
- Readability. The caller sees one node ("Parse date") instead of five.
- Testability. Run the sub-workflow on its own with pinned data.
- Replaceability. Swapping implementations doesn't ripple to callers.
Cost: an extra workflow boundary.
For most 5+ node chunks doing one logical thing, extraction is worth it.
1.5 The fire-and-forget audit-log pattern
Audit logging is used here as a concrete illustration of the fire-and-forget stateful pattern. Don't add audit logging to a workflow unless the user asked for it. The pattern itself (fire a sub-workflow async, don't block on it) generalizes to any side observation: metrics, notifications, etc.
A deliberately stateful audit-log sub-workflow invoked with
's
waitForSubWorkflow: false
so the caller doesn't block on the write.
Caller ──→ [Execute Workflow: DB audit log]
{ title: 'Email Confirmation Received',
description: <serialized data> }
waitForSubWorkflow: false
↓ (caller continues immediately)
──→ [Continue with next step]
The sub-workflow takes a title and description, writes to a logging table (or Slack, or both), returns. The caller doesn't wait. Audit log is a side observation, not the critical path.
When the user has asked for it, fire one at every meaningful state transition ("email confirmation received", "user verified", "processing started", "eligibility decision made") so the timeline reconstructs from logs.
Why it's valuable:
- Observability for free. Per-execution timeline when something goes wrong.
- No coupling. Implementation (DB, Slack, both) can change without touching callers.
- Async by default.
waitForSubWorkflow: false
means the audit doesn't slow the main workflow.
The audit-log workflow is the right kind of stateful sub-workflow. The side effect is the point.
1.7 The middleware pattern
When a webhook workflow is API-shaped, treat it like one. Sub-workflows become middleware: small stateless functions that run before the main handler and either pass through or short-circuit with a 4xx.
Webhook
→ [Subworkflow: Verify JWT] # decode + validate; 401 on failure
→ [Subworkflow: Rate limit] # check + bump counter; 429 on failure
→ IF (all middleware ok)
→ Main handler logic
→ Respond 200
→ ELSE → Respond with the 4xx the middleware returned
Canonical example: custom JWT auth rolled inside n8n.
takes the raw
header, decodes, validates signature and expiry, returns
or
{ ok: false, status: 401, message }
. The caller IFs on
, responds early on failure, continues on success.
Why a sub-workflow and not inline: every webhook that needs auth calls the same one. Swap the library, rotate the signing key, or add refresh-token logic in a single place. The reuse target is exact, the contract is small, and the failure response shape is consistent across every API endpoint.
Pairs with
for 4xx/5xx response shapes and
n8n-credentials-and-security
for the underlying secret handling.
2. Repetition pattern
You're about to build something you've built before. Stop. Search.
search_workflows({ query: 'date' })
search_workflows({ query: 'Customer' })
search_workflows({ query: 'Subworkflow:' })
If something matches, use it. If not, build it as a sub-workflow so the
next search finds it. The prefix convention (
,
, etc.) is what makes that work.
Linear, long workflows are fine when most of the work is in sub-workflows
A workflow can have 20+ nodes and still be readable if it's mostly a linear orchestration of sub-workflow calls and decisions. The shape (audit-log nodes shown only because they're a vivid example of "side observation between real steps", include them only if the user asked for audit logging):
Webhook
→ Audit log (sub-workflow)
→ Validate
→ Audit log (sub-workflow)
→ IF auth ok
→ Look up user (or sub-workflow)
→ Audit log (sub-workflow)
→ Process step 1 (sub-workflow)
→ Audit log (sub-workflow)
→ Process step 2 (sub-workflow)
→ Audit log (sub-workflow)
→ Decide eligibility (sub-workflow)
→ Audit log (sub-workflow)
→ Send notification (sub-workflow)
→ Respond
Each "logical step" is a sub-workflow call. The caller is a long but linear narrative, easy to follow top-to-bottom. Logic lives in the sub-workflows.
This is not the same as a 20-node workflow with 20 inline transformations. That's hard to read. The pattern above is fine because:
- Each node has one purpose (call a specific sub-workflow).
- Sticky notes group sections (per "Readability").
- Inspecting a section means opening the sub-workflow it calls. That's encapsulation.
- Orchestration logic at the top level is visible without reading implementations.
If your workflow has 15+ nodes and isn't mostly Execute Workflow calls and branches, extract more.
When NOT to extract
- One HTTP call with no logic. A sub-workflow that's just
Execute Workflow → HTTP Request → return
adds a boundary for nothing. Inline it.
- Tightly coupled to the caller's specific shape. If the chunk takes a deeply nested input that only this caller produces, extracting it just relocates the coupling. Fix the data shape first.
- Performance-critical hot paths. Each sub-workflow call adds latency (small, but real). For high-throughput workflows, profile before adding boundaries.
Search-before-build protocol
When the user describes something multi-step or generic-sounding:
1. search_workflows with relevant queries (e.g. 'Subworkflow', the domain prefix, the operation keyword)
2. If candidates appear, fetch get_workflow_details on the top 1-3
3. Confirm fit by reading the inputs/outputs and (briefly) the body
4. If a fit exists → use it. Tell the user "I found `<name>`. Using that."
5. If no fit exists → build new with the prefix convention so the next search finds it
The "tell the user" step matters. They benefit from knowing what's already in their library.
If a workflow you expect to find isn't appearing, the most common cause is per-workflow MCP access not being enabled. See
references/MCP_ACCESS_PER_WORKFLOW.md
.
Sub-workflow inputs and outputs
Sub-workflows are triggered by
nodes. The trigger declares the input schema. The caller passes data via
, and the sub-workflow returns whatever its last node outputs.
Always use "Define Below" with explicit fields
The
has two input modes.
Default to "Define Below" (typed fields). This is the only mode that lets agent tools (via
) and any structured caller pass values in. Without declared fields, the agent has no schema to fill and the sub-workflow can't be wired as a
cleanly.
Shape:
ts
const subTrigger = trigger({
type: 'n8n-nodes-base.executeWorkflowTrigger',
config: {
parameters: {
workflowInputs: {
values: [
{ name: 'list_of_ids', type: 'array' },
{ name: 'include_transcript', type: 'boolean' },
{ name: 'session_id', type: 'string' },
],
},
},
},
})
Each declared input becomes a typed parameter the caller can fill. Inside the workflow, access via
, etc., or
$('When Executed by Another Workflow').first().json.<field>
from anywhere downstream.
Pick types deliberately (
,
,
,
,
). The model uses these as the required types when filling agent tool parameters, and humans rely on them when wiring callers.
The one exception: passthrough mode for binary
If the sub-workflow needs to receive binary (image, file, PDF),
doesn't work because typed fields are JSON only. Switch to passthrough:
ts
const subTrigger = trigger({
type: 'n8n-nodes-base.executeWorkflowTrigger',
config: {
parameters: {
inputSource: 'passthrough',
},
},
})
In passthrough mode, the sub-workflow receives the caller's items as-is, including the
slot. Cost: no typed input schema, so agent tools can't pass parameters through
. Use this mode for sub-workflows called by other workflows (not agents) where binary needs to flow through.
For sub-workflows that need binary AND are called by an agent, see
references/AGENT_TOOL_BINARY.md
(agent tools can't pass binary directly).
Other conventions
-
Document inputs and outputs in the workflow . Field names, types, purpose. The description is what callers (humans and agents) read for the contract.
-
Return a consistent shape. For expected failures (e.g., parse error), return
{ success: false, error: '...' }
rather than throwing. Callers can branch without wrapping error outputs.
-
Treat the input schema as a contract once it has callers. Adding optional fields is safe. Renaming or removing fields can be done, but only carefully: enumerate every caller (
for the sub-workflow's name + manual scan), migrate them in the same change, and verify with
+
before publishing. A silent break here is hard to detect because n8n won't error on an unrecognized input field. The sub-workflow just sees
and the caller has no idea.
-
Use a final Set / Edit Fields node to shape the return. Optional, sometimes required (when the last computation node carries noise fields), and good practice for sub-workflows even when not strictly required. It makes the return contract explicit at the boundary, so readers see the API by reading one node. This is the legitimate exception to the Set-node antipattern from
: the implicit consumer of a sub-workflow's last node is
every caller, so the Set earns its place as the explicit API boundary. Name it
or
.
-
Return natural shapes, not storage shapes. A sub-workflow that owns a Data Table, a file in S3, or any storage layer should hide that representation from callers. Arrays return as arrays, objects as objects, dates as ISO strings, regardless of whether the underlying storage was JSON-stringified text or another internal format. The return contract is the interface. The storage layout is implementation detail.
Common slip: a sub-workflow has a "fresh" path (data just produced, natural shape) and a "cached" path (data just read from a
column, still stringified). Wrong instinct: stringify the fresh path "to match" the cached path. Right instinct: parse the cached path so both return the natural shape. Callers shouldn't have to know which they got.
For sub-workflows wired as agent tools specifically, see
references/SUBWORKFLOW_AS_TOOL.md
.
Calling sub-workflows: modes
Two settings on the caller-side
node beyond inputs/workflowId:
- defaults to : the sub-workflow runs once with all N items as input. Items still flow through nodes per-item like any other workflow. Set to run the sub-workflow N separate times, one item per execution. For sub-workflows whose body just processes items normally, the two are equivalent. The split matters when the sub-workflow's body assumes it sees exactly one item (per-run aggregation, "this is THE customer to operate on" logic, a final write that should fire once per input). matches that assumption, breaks it. When you DO need per-item iteration, prefer over a Loop Over Items node inside the sub-workflow.
- defaults to . Setting
options.waitForSubWorkflow: false
fires the call and immediately moves on, and the sub-workflow continues in the background. The caller's downstream sees no return data.
+
waitForSubWorkflow: false
is
the only true parallelization n8n offers: N sub-workflow executions dispatched without waiting, running concurrently (still bounded by per-instance concurrency limits and per-call overhead). Useful for "kick off N independent jobs, poll/aggregate later". For example: dispatch a long-running job per item, track each in a Data Table, then loop until all rows mark themselves complete or time out.
For the polling-after-fire-and-forget pattern, see
references/SUBWORKFLOW_PATTERNS.md
"Fire-and-forget parallelization".
Reference files
| File | Read when |
|---|
references/SUBWORKFLOW_PATTERNS.md
| vs default, splitting by input shape (binary/passthrough vs Define Below), fire-and-forget parallelization with Data Table polling |
references/NAMING_AND_DISCOVERY.md
| Naming a new sub-workflow, searching for existing ones, the prefix convention |
Anti-patterns
| Anti-pattern | What goes wrong | Fix |
|---|
| Duplicating the same date-parsing nodes in three workflows | Bug fixes happen in two places, miss the third | Extract to Subworkflow: Parse <format> date
once |
| Building a new sub-workflow without searching | Library grows duplicates, and future searches find both | Always first |
| Sub-workflow named/described as pure that quietly writes to a log table | Callers can't reason about retry or idempotency, side effect ambushes them | Either make the side effect part of the contract (rename, document, return its result) or move it out |
| Sub-workflow with no | Won't be found in future searches, nobody knows what it does | Set with input/output shape and purpose |
| Sub-workflow named | Name doesn't tell anyone what it does, and doesn't match any prefix-based search | Verb-first prefix name (, ), see |
| Sub-workflow with no / domain prefix | Won't show up under or domain searches, future you can't find it | Always use a prefix at create time |
| set to when not handling binary | No typed schema means agent tools can't fill parameters via , structured callers can't pass values cleanly | Use "Define Below" with declared (name + type per field) |
| Sub-workflow called as an agent tool that expects binary input | Agent tools can't pass binary directly | See for the right pattern |
| 30-node workflow with no extraction | Hard to read, hard to test, hard to replace | Extract logical sections into sub-workflows |