n8n tutorial - Lesson 21: Sub-Workflows in n8n: Build Modular Automation

Hi everyone, in this n8n sub workflow tutorial, you'll learn how to split a large automation into reusable child workflows using the Execute Workflow node. This is a core pattern in n8n workflow automation that keeps your projects modular, maintainable, and easy to scale.

How to do:

Step 1 — Understand When to Use Sub-Workflows

Before building anything, know the five situations where the sub-workflow pattern pays off in n8n workflow automation.
  1. Use a sub-workflow when the same logic is called from multiple parent workflows — for example, a "post to Blogger" routine used by both a blog factory and an email pipeline.
  2. Use it when a single workflow exceeds ~15 nodes and becomes hard to read or debug.
  3. Use it when you want to test one processing unit (text formatting, image generation, number math) independently without running the full parent.
  4. Use it when different team members own different stages — each person edits their own child workflow without touching shared logic.
  5. Use it when you need to process each item in a loop through a consistent set of nodes — the parent iterates, the child does the work.

Step 2 — Build Child Workflow A (Text Processor)

The first child workflow, T6-B2-Child-A-TextProcessor, receives a text string, transforms it to uppercase, and returns the result.
  1. Create a new workflow named T6-B2-Child-A-TextProcessor.
  2. Add a When Executed by Another Workflow trigger node. In the Input Schema, define one field: name text, type String.
  3. Add a Set node after the trigger. Configure three output fields:
    • result → expression {{ $json.text.toUpperCase() }}
    • processedAt → expression {{ $now.toISO() }}
    • processedBy → fixed string Child-A-TextProcessor
  4. Save the workflow, then click Test workflow with pinned data {"text": "hello sub workflow"}. Expected output: result: "HELLO SUB WORKFLOW".

Note — Depending on your n8n version, the When Executed by Another Workflow trigger's schema screen may only show Name and Type fields — there is no auto-generated sample value input box. You must pin test data manually to run standalone tests.

Step 3 — Build Child Workflow B (Number Processor)

The second child, T6-B2-Child-B-NumberProcessor, receives a number and returns its square — demonstrating that each child can use a completely different node type.
  1. Create a new workflow named T6-B2-Child-B-NumberProcessor.
  2. Add a When Executed by Another Workflow trigger. Define one schema field: name number, type Number.
  3. Add a Code node with the following JavaScript:
    • const n = items[0].json.number;
    • return [{ json: { squared: n * n, processedAt: new Date().toISOString(), processedBy: "Child-B-NumberProcessor" } }];
  4. Save and test with pinned data {"number": 7}. Expected output: squared: 49.

Step 4 — Build the Parent Dispatch Workflow

The parent workflow, T6-B2-Parent-Dispatch, sends data to both children in parallel and merges the results.
  1. Create a new workflow named T6-B2-Parent-Dispatch.
  2. Add a Manual Trigger node, then a Set node to mock input — set text to hello sub workflow and number to 7.
  3. From the mock input node, create two parallel branches:
    • Branch 1: Execute Workflow node → select T6-B2-Child-A-TextProcessor. Set Run once for all items. Map text{{ $json.text }}.
    • Branch 2: Execute Workflow node → select T6-B2-Child-B-NumberProcessor. Set Run once for all items. Map number{{ $json.number }}.
  4. Add a Merge node connected to both branches. Set Mode to Combine By Position.
  5. Run the parent and verify the merged output contains fields from both children: result, squared, and both processedAt values.

Tip — Use Combine By Position (not Combine By Matching Fields) when your two branches don't share a common key field. Combine By Matching Fields requires a shared identifier — in this demo, there is none.

Step 5 — Know the 7 Gotchas Before Going Further

These are the practical issues you will hit in real n8n sub workflow builds.
  1. Field name collision: If Child A and Child B both output a field called processedAt, the Merge node will overwrite one with the other. Rename fields to be unique per child (e.g., processedAt_A vs processedAt_B).
  2. Timezone offset: $now.toISO() returns local time; new Date().toISOString() in a Code node returns UTC. Be consistent across children to avoid confusion in logs.
  3. Cross-node reference breaks after deleting a node: If your child was duplicated from another workflow and still references a deleted node — e.g., $('Get Topics') — the expression will throw an error. Update every reference to point to the new trigger: $('When Executed by Another Workflow').
  4. Double-equals bug: In the Execute Workflow input mapping, if the field is already in Expression mode, type {{ $json.topic }} — do NOT prefix it with =. Writing ={{ $json.topic }} passes the literal string =Hướng dẫn... as the topic title instead of the actual value.
  5. Schema only validates, it does not inject sample data: The trigger schema defines expected input types. It does not create a test input form — pin data manually for standalone testing.
  6. Run mode matters: Run once for all items calls the child once and passes all items in a batch. Run once for each item calls the child once per item. Use "each item" when the child must process one topic at a time (e.g., the Content Factory).
  7. On Error behavior: Each Execute Workflow node has an On Error setting with three options: Stop Workflow (default — halts everything), Continue (skips the failed item silently), Continue (using error output) (routes the error to a separate output pin for handling). For production, set this to Continue (using error output) so one bad topic doesn't kill the whole batch.

Step 6 — Refactor Content Factory into Parent + Child

Now apply the sub-workflow pattern to a real project: the existing T4-B5-Blog-Batch workflow (14 nodes) is split into a parent dispatcher and a child blog generator.
  1. Design the split:
    • Parent (T6-Content-Factory-Dispatch): reads topics from Google Sheet T4-B5-Blog-Topics, calls the child for each topic, marks the sheet row as done.
    • Child (T6-Content-Child-Blog): receives one topic string, generates HTML blog post, posts draft to Blogger, returns topic / status / postId / blogUrl.
  2. Build the child workflow:
    1. Duplicate T4-B5-Blog-Batch. Rename it T6-Content-Child-Blog.
    2. Delete the Get Topics, Limit, and Mark Done nodes.
    3. Add a When Executed by Another Workflow trigger with schema field topic (type String).
    4. Find every expression that references $('Get Topics') and replace it with $('When Executed by Another Workflow').
    5. Add a final Set node (Build Output) that returns: topic, status, postId (from $json.id of the Blogger POST response), blogUrl (from $json.url).
  3. Build the parent workflow:
    1. Duplicate T4-B5-Blog-Batch. Rename it T6-Content-Factory-Dispatch.
    2. Delete the 11 internal processing nodes, keeping only: Manual TriggerGet TopicsLimitMark Done.
    3. Insert a Call Child Blog (Execute Workflow) node between Limit and Mark Done. Select T6-Content-Child-Blog. Set Run to Run once for each item.
    4. Map the child input: field name topic, value {{ $json.topic }} (no leading =).
    5. In the Mark Done node, set the match condition to topic = {{ $json.topic }} — this uses the topic field returned by the child output, ensuring the correct sheet row is marked regardless of item ordering.

Production tip — Always match Mark Done using a value from the child's output (not the parent's pre-call data). If the child transforms or normalizes the topic string, matching against the child's returned topic field prevents row-pairing mismatches in your sheet.

Step 7 — Test the Full Content Factory and Fix Errors

Run the parent with one topic in the sheet and work through the three errors that appear in a typical first run.
  1. Error 1 — DALL-E response_format known issue:
    • The OpenAI DALL-E node throws an error related to the response_format parameter on certain n8n versions.
    • Workaround: disable the DALL-E node and its 3 downstream image-handling nodes in T6-Content-Child-Blog. The blog post will be created without an image until this is fixed in Session 22.
  2. Error 2 — Double-equals in topic title:
    • Symptom: the blog post title appears as =Hướng dẫn... (the raw expression string, not the evaluated value).
    • Fix: open the Call Child Blog node, find the topic input field, confirm it is already in Expression mode, and change the value from ={{ $json.topic }} to {{ $json.topic }}.
  3. Error 3 — Broken cross-node reference:
    • Symptom: child workflow throws Referenced node does not exist: Get Topics.
    • Fix: search the child for every expression containing $('Get Topics') and replace with $('When Executed by Another Workflow').
  4. After all three fixes, run the parent again. Expected results:
    • Blogger draft created with clean title ✅
    • Google Sheet row marked as done ✅
    • No expression errors ✅

Note — The DALL-E issue is a known bug in specific n8n versions. The fix — replacing the OpenAI node with an HTTP Request node calling images/generations directly without the response_format parameter — is scheduled for the next session.

Key Lessons from This Session

  1. Always test each child workflow standalone before connecting to the parent. Pin sample data to the trigger and verify output before wiring the Execute Workflow node in the parent.
  2. Use Combine By Position when branches share no common key field. Combine By Matching Fields requires a shared identifier — if none exists, the merge will silently drop rows.
  3. The double-equals bug (={{ }}) passes a literal string, not an expression. If an input field is already in Expression mode, write {{ $json.field }} without the leading =.
  4. Cross-node references break when the referenced node is deleted. After duplicating a workflow and removing nodes, audit every expression for references to deleted node names.
  5. Match Mark Done using the child's output topic, not the parent's pre-call data. This prevents sheet row mismatches when items are processed out of order.
  6. Set Execute Workflow error handling to "Continue (using error output)" in production. This routes failed items to a separate pin instead of stopping the entire batch.
  7. Run once for each item vs. run once for all items controls batching behavior. Use "each item" when the child must handle one record at a time with its own context.

Conclusion:

In this n8n sub workflow tutorial, you built a full three-workflow demo — two standalone child processors and a parent dispatcher — then applied the same pattern to a real Content Factory by refactoring a 14-node monolith into a clean parent-plus-child architecture. The sub-workflow pattern is the foundation for orchestrating multi-format content pipelines in n8n tutorial series like this one, and every production technique covered here (error routing, expression mode awareness, cross-node reference auditing) will carry forward into more advanced n8n workflow automation builds. Next session covers re-enabling the DALL-E image branch with a direct HTTP Request workaround, verifying postId and blogUrl output values, and expanding the factory to dispatch to YouTube and Email child workflows.

If you have any questions, feel free to leave a comment below. Thank you!

Tags: n8n sub workflow tutorial, n8n tutorial, n8n workflow automation, Execute Workflow node, modular automation, n8n Content Factory, n8n beginner to advanced, workflow design patterns

Maybe you are interested!