Splitting a workflow in two, and putting it back together
Everything so far has been a straight line: data arrives, each node hands it to the next. Real automations are not like that. An order over 1,000 goes to the manager and the rest go on a list. Five hundred rows have to reach an API that allows five calls a second. Two branches finish separately and you want one notification at the end. This article covers the nodes that split a path, and the nodes that join it back up.
Passing data along is not the same as deciding what to do with it
By now you can write {{ $json.amount }} and push a value from one node into the next. That is passing something along untouched. Almost every automation anyone actually runs has a fork in it: notify the manager only when the amount is over 1,000; email the thank-you note only to VIP customers; store the form only if the consent box was ticked; make critical alerts ring a phone while info only writes a log line.
Read the data, then pick a path. Programmers write that as if / else or switch / case. In n8n you write no code: you drop in an IF node or a Switch node and draw the downstream connections by hand. Data comes in, the node decides, and the item leaves through the top or the bottom (IF) or through a numbered output (Switch).
It is the sorting table in a post room. Every envelope goes past the same person, who reads one thing on it — the postcode, the department, whether it is marked urgent — and drops it into one of the trays. That person is not doing the work. That person is deciding whose desk the work lands on.
Splitting is only half of it. The other half arrives once you have built a few workflows: you split with IF and cannot work out how to join the paths back; you feed 500 items to an API that allows 5 a second and collect 429 Too Many Requests from the sixth onward; an API returns { total: 5, items: [...] }, which counts as 1 item, so the Slack node downstream sends once instead of five times; two APIs each return half a record and you need them stitched on the same customer_id. Those are the jobs of Merge, Loop Over Items, Split Out and Aggregate.
splitInBatches type stays compatible.Two paths, or several
Both nodes decide, then route. Picking the wrong one breaks nothing, but the right one keeps the canvas readable.
| Characteristic | IF node | Switch node |
|---|---|---|
| Outputs | Always two: true (top), false (bottom) | As many as you define. Rules mode has no documented hard limit; 3–10 is common in practice. In Expression mode Number of Outputs decides |
| The question it answers | One yes/no — is the amount over 1000? | One field with several values — is tier gold, silver or bronze? |
| Mental model | A program's if / else |
A program's switch / case. Like an elevator: press a floor, it takes you there |
| How to choose | Only two paths: yes or no | Three paths or more, or an obvious category — tier, status, source |
flowchart LR
subgraph SW["Switch · one field, several values"]
direction LR
S0["An item arrives"] --> S1{"read tier"}
S1 -->|"gold"| S2["output 0"]
S1 -->|"silver"| S3["output 1"]
S1 -->|"bronze"| S4["output 2"]
S1 -->|"nothing matched"| S5["fallback output"]
end
subgraph IFN["IF · one yes-or-no question"]
direction LR
I0["An item arrives"] --> I1{"amount over 1000"}
I1 -->|"true"| I2["Notify the manager"]
I1 -->|"false"| I3["Daily log"]
end
An order over 1,000 goes one way, everything else the other
Assume your upstream node — a Webhook, Google Sheets, Airtable, whichever you have — hands the order down as JSON in this shape:
{ "amount": 1500, "customer": "Example Stationery Store" }-
Step 1
Add an IF node
Click the + icon to the right of the upstream node, or press
Tabon an empty spot, to open the nodes panel. Typeifand click If, under the Flow category. It drops onto the canvas already connected upstream. -
Step 2
Set the first condition
Double-click the node. A Conditions block appears with one empty comparison row, three fields left to right:
Value 1Click the field to switch to Expression mode and write{{ $json.amount }}.OperatorPick the type Number on the left first, then is greater than on the right — the official name in the v2 IF node, not larger.Value 2Enter1000. A plain number, no quotes. -
Step 3
Wire the two outputs that appear
The node now shows true on top in green and false below in red. Drag from true into your “notify the manager” node — a Slack node posting to a manager channel — and from false into the “daily log” node, a Google Sheets Append Row for example.
-
Step 4
Save, then test the true path
Press
Ctrl+S, then click Execute Workflow at the top right. The IF node marks the output it took with a green border. The amount is 1500, over 1000, so it should take true — and the Slack node should light up too. -
Step 5
Test the false path as well
Change the upstream mock data to
amount: 800and run again. Only the Google Sheets path should light up. An IF is only verified once you have tested both sides.
Four data types, and the mistake everyone makes once
The Operator dropdown has two levels: pick the data type on the left, and the comparisons for that type appear on the right. Get the type wrong and the condition reads perfectly but comes out false every run.
| Type | Operators | Value 2, and an example |
|---|---|---|
| String | exists / does not exist / is empty / is not empty / is equal to / is not equal to / contains / does not contain / starts with / does not start with / ends with / does not end with / matches regex / does not match regex | A string; an expression is allowed. {{ $json.status }} · is equal to · paid |
| Number | exists / does not exist / is empty / is not empty / is equal to / is not equal to / is greater than / is less than / is greater than or equal to / is less than or equal to | A plain number, no quotes. {{ $json.amount }} · is greater than · 1000 |
| Boolean | exists / does not exist / is empty / is not empty / is true / is false / is equal to / is not equal to | Nothing at all for is true and is false; for is equal to, true or false. {{ $json.consent }} · is true |
| Date & Time | exists / does not exist / is empty / is not empty / is equal to / is not equal to / is after / is before / is after or equal to / is before or equal to | An ISO date string or an expression such as {{ $now }}. {{ $json.createdAt }} · is after · 2026-01-01 |
Two more come up less often: Array (contains, length equal to, length greater than) and Object (only exists, does not exist, is empty, is not empty). Note that Date & Time has no is between — to test “between two times” write two conditions, is after A and is before B, combined with AND.
A price tag reading 1500 and the number 1500 are not the same thing to a computer. One is a piece of paper with ink on it; the other is a quantity you can compare. Ask a machine whether the piece of paper is bigger than a thousand and it shrugs, every time, forever.
"1500", you picked Number → is greater than → 1000, and it is false on every run. The string 1500 is not the number 1500, and n8n does not convert it for you. Fix it in Value 1 with {{ Number($json.amount) }}, or convert upstream with an Edit Fields (Set) node. Whenever a condition should match and does not, this is nearly always why.Combining conditions with AND and OR
Add a second condition row and an AND / OR dropdown appears between the two. The interface calls it exactly that; there is no Combinator label to hunt for. AND is worded “Keep data when it meets all conditions”. OR is worded “Keep data when it meets any of the conditions”.
{{ $json.amount }} Number is greater than 1000; condition 2 {{ $json.status }} String equals paid. With status pending, even 5,000 goes false.{{ $json.tier }} String equals vip; condition 2 {{ $json.amount }} Number is greater than 5000.{{ $json.a && ($json.b || $json.c) }} and set Operator to Boolean is true. The second is tidier, but you need a little JavaScript.Rules mode, Expression mode, and what happens to the leftovers
Open a Switch node and the first thing it asks is Mode: Rules or Expression. Rules works like a stack of IF nodes — every rule has its own condition, using the operator system you just learned, and an item leaves through the output belonging to whichever rule matched. Click Add Routing Rule and you get one more output. Three customer tiers, with { "tier": "gold", ... } arriving from upstream:
{{ $json.tier }} String equals gold → output 0, the thank-you email and a note to sales{{ $json.tier }} String equals silver → output 1, the discount voucher{{ $json.tier }} String equals bronze → output 2, the standard welcome emailBelow the rules sits Fallback Output, with three official options: None (the default — an unmatched item is dropped), Extra Output (one more output catching unmatched items, the common choice) and Output 0 (unmatched items leave with the first rule's). When you want an “unclassified” path, pick Extra Output.
flowchart TD
A["One item enters the Switch"] --> B{"Rule 0 matches?"}
B -->|"yes"| B1["Leaves through output 0.
The rules below are skipped"]
B -->|"no"| C{"Rule 1 matches?"}
C -->|"yes"| C1["Leaves through output 1"]
C -->|"no"| D{"Rule 2 matches?"}
D -->|"yes"| D1["Leaves through output 2"]
D -->|"no"| E{"Fallback Output setting"}
E -->|"None, the default"| E1["The item is dropped.
Nothing errors"]
E -->|"Extra Output"| E2["Leaves through
the extra output"]
E -->|"Output 0"| E3["Leaves with the
first rule's items"]
A Send data to all matching outputs toggle sits under Options, off by default; with it on, an item matching two rules is sent down both, so items get duplicated across branches. Because of the top-to-bottom order, put the strictest rule at the top. To stop caring about order entirely, write rules that exclude each other: amount >= 100 AND amount < 1000, then amount >= 1000 AND amount < 5000, then amount >= 5000 — each range built from two conditions plus AND, since Number has no is between.
Expression mode instead takes one expression returning the output number, counting from 0. The same three tiers:
{{ { "gold": 0, "silver": 1, "bronze": 2 }[$json.tier] }}Read it as a lookup table, and set Number of Outputs first — 3 here — so the node has that many lines to connect. Neither mode is the advanced one: Rules is friendlier because you read the logic off the canvas, Expression saves setup time when there are many categories and the mapping is one-to-one. Both give the same result.
| Situation | Node and condition | Wiring |
|---|---|---|
| Store the form only if consent was ticked, otherwise ask for it again | IF: {{ $json.consent }} Boolean is true |
true → Google Sheets Append; false → Gmail Send Message |
| Tiered notifications: under 1000 the assistant, 1000 to 5000 the team lead, over 5000 the manager | Switch (Rules): rule 0 amount under 1000; rule 1 two conditions with AND, at least 1000 and at most 5000; rule 2 over 5000 | One Slack node each, posting to a different channel |
| Route incoming mail by the sender's domain | Switch (Rules): {{ $json.from.split('@')[1] }}, one String equals rule per domain |
Each to a Gmail Add Label or Move Message |
| System alerts by severity | Switch (Expression): {{ { critical: 0, warning: 1, info: 2 }[$json.level] }} |
output 0 → HTTP Request to the phone API; output 1 → Slack; output 2 → HTTP Request that writes the log |
A webhook receives a payload and each event_type needs a different path |
Switch (Rules): one event_type string per rule |
Each rule into its own chain of handling nodes. Turn the fallback on and wire an “unknown event” alert to it, or a payload you have never seen before vanishes |
Four modes, and three more choices inside one of them
The Merge node always needs two inputs — you see two connector dots when you draw a connection to it. Its Mode dropdown has four options. This is the structure n8n moved to after 2024, and it is not the “three modes” older tutorials describe.
| Mode | What it does | Notes |
|---|---|---|
| Append | Keep data from all inputs: stacks both sides one after the other | 3 items plus 2 items gives 5. Like pasting rows under rows in a spreadsheet |
| Combine | Combine data from two inputs: joins the fields side by side, then you pick a Combine By strategy | The most-used mode. It replaces the old Combine by Position / by Key / Multiplex |
| SQL Query | Write SELECT ... FROM input1 JOIN input2 ... and define the rule yourself | Run on alasql underneath. Use it for multi-field joins, CASE WHEN, a UNION with a WHERE, subqueries — not for a plain append |
| Choose Branch | Keeps only one side: of the two branches, carry this one on downstream | Not a merge, a choice. It replaces the old trick of rigging this up with two IF nodes |
Under Combine sit three strategies. Matching Fields pairs items on a key value, the same as a SQL JOIN — customer data on id against order data on customer_id. Position aligns by position in the array, so [{a:1},{a:2},{a:3}] plus [{b:x},{b:y},{b:z}] gives [{a:1,b:x},{a:2,b:y},{a:3,b:z}]. All Possible Combinations is the Cartesian product: 3 items and 2 items give 6.
With Matching Fields selected, Output Type decides the kind of join, and all five are there. Keep Matches outputs only items matched on both sides, an inner join. Keep Non-Matches outputs only the unmatched, which is how you answer “which customers have never ordered”. Keep Everything keeps both sides and fills unmatched fields with null, a full outer join. Enrich Input 1 lets Input 1 lead and pastes on the matching fields from Input 2, a left join; Enrich Input 2 is the mirror image.
One behavior catches almost everybody once. A Merge node only runs when data has arrived at both inputs. Rejoining after an IF split, if one path is meant to do nothing, wire a No Operation, do nothing node into Merge anyway — or Merge waits forever, quietly, with no error to explain itself.
It is arranging to meet someone at the station entrance. You are there, they went home instead, and nobody rings to cancel. You are not stuck because anything broke. You are stuck because the arrangement only completes when both people show up, and one of them was never coming.
flowchart TD
A["Upstream node"] --> B{"IF"}
B -->|"true"| C["Edit Fields (Set) A"]
B -->|"false"| N["No Operation,
do nothing"]
C --> M["Merge
Mode = Append"]
N --> M
M --> S["Slack, written once"]
Two last details. Ordering has a rule per mode: Append gives all of Input 1 then all of Input 2, Position keeps the same index aligned, and Matching Fields orders by the order the keys appear in Input 1 — put a Sort node before or after the Merge if you need a particular order. And the node has connectors for two inputs only: to merge three or more, chain two Merge nodes, A and B into the first, its output plus C into the second.
Batching, flattening and rolling back up
| Node | In and out | Classic use |
|---|---|---|
| Loop Over Items (formerly Split In Batches) | One input with many items; two outputs, loop for this round's items and done for when everything has finished | API rate limits, memory, a progress message per batch |
| Split Out (formerly Item Lists · Split Out Items) | One item in, many out — the array flattened | An API returning { items: [...] }, a Gmail attachment array |
| Aggregate (formerly Item Lists · Aggregate Items) | Many items in, one out | Report roll-ups, one daily summary instead of many alerts |
| Sort / Limit / Remove Duplicates / Summarize | Many in; many or one out | The other old Item Lists operations, each its own node now |
The Airtable API allows at most 5 requests a second and your sheet has 500 rows to write. Wire it straight through and after the fifth item you start collecting 429s, with the remaining 495 failing. Loop Over Items is the fix, and its unusual part is that the node forms the loop itself: the nodes on the loop side have to draw a connection back to it, and that back-connection is what makes it a loop.
flowchart TD A["Google Sheets
500 items"] --> B["Loop Over Items
Batch Size = 5"] B -->|"loop"| C["Airtable Create"] C --> D["Wait, 1 second"] D -->|"the connection back is
what makes this a loop"| B B -->|"done, taken once
everything has run"| E["Slack: 500 records processed"]
Batch Size sets how many items each round sends to the loop output: 5 for Airtable, 10–50 for a typical API. Options → Reset decides whether the counter resets when new items arrive — leave it off for a one-shot workflow, turn it on only when the workflow is called continuously. The done output is taken once, after everything has run, and feeds an “all finished” notification.
Split Out and Aggregate
A Gmail message whose payload holds an attachments array with five attachments is 1 item, and an upload node downstream would run once. Split Out is what turns a JSON array into n8n items — the answer to the point made earlier in this series, that n8n items and a JSON field called items are two different things. Fields To Split Out names the field holding the array, comma-separated for several. Include chooses whether each item that comes out carries the original's other fields, such as the mail subject: No Other Fields / All Other Fields / Selected Other Fields. Under Options, Destination Field Name names the key each payload sits under, or flattens it when left empty, and Disable Dot Notation (off by default) is for field names that themselves contain a ..
Aggregate is the opposite: five processed items collapsed into one summary. Individual Fields aggregates only the fields you name, with Rename Field and Merge Lists to flatten nested arrays. All Item Data (Into a Single List) packs every item's whole json into one array, with Include and Exclude lists.
A delivery arrives as one box with five parcels inside, so the courier's system scans it once. Split Out is opening the box and putting five parcels on the counter, each scanned in its own right. Aggregate is packing them back into one box at the end of the day so you send one note instead of five.
Worked example: one Slack reminder per Google Sheet row
Start with the case that needs none of this section's nodes, because it shows you what “already many items” looks like. A Google Sheets read turns every row into an item on its own, so the work is done before you get there.
-
Step 1
Add a Manual Trigger
Create a workflow and pick Trigger manually as the first node. Pressing Execute Workflow then runs it once, on demand.
-
Step 2
Add a Google Sheets node
Wire on a Google Sheets node and set Operation to Get Rows in Sheet. Connect the credentials — Part 10 of this series covers that — then pick your spreadsheet and sheet. Say the spreadsheet is called “Customer list”, has 10 rows, and its fields are
name,emailanddue_date. -
Step 3
Execute step and confirm 10 items arrive
Press Execute step on the Google Sheets node. The top of the output panel on the right shows 10 items — check the count is right before you go on. Switch to the Table view to read each row.
-
Step 4
Add a Slack node
Wire on a Slack node, set Operation to Send Message and pick a channel —
#billing-reminder, for example. Switch the Text field to Expression mode and enter:Statement reminder for {{ $json.name }}. Due {{ $json.due_date }} — please take care of it. -
Step 5
Execute the whole workflow once
Press Execute Workflow at the top right. The Slack node's output panel shows 10 items — 10 messages sent, one per row. Open the channel and confirm 10 actually arrived.
-
Step 6
Save it
Save at the top right. From then on, pressing Execute Workflow sends another round. To send it automatically on the 1st of each month, swap the Manual Trigger for a Schedule Trigger, which Part 7 of this series covers.
Worked example: split the array an API returns and act on each entry
Try it end to end. Add a Manual trigger, then an HTTP Request node with Method GET and URL https://jsonplaceholder.typicode.com/users. Press Execute step and read the top of the output panel: 1 item, not 10, because the whole response is one array. Wire on a Split Out node and put the path of that array in Fields To Split Out — if HTTP Request puts it in data or at the root, use the path that matches, read off the output panel. Execute step again and the panel shows 10 items. A Slack node with Send Message and Text set to Welcome {{ $json.name }} ({{ $json.email }}) now sends 10 messages instead of one.
To roll it back up, add an Aggregate node with Aggregate set to Individual Fields and Field To Aggregate set to name, renamed to names. The output is back to 1 item holding an array of 10 names, and a Gmail node can send Processed these 10 users today: {{ $json.names.join(', ') }} — one summary instead of ten alerts.
data.results, for example.Four combinations you will reach for most weeks
Knowing one node is only the start. Almost every real workflow is two or three of them standing next to each other.
| Combination | What it solves | Structure |
|---|---|---|
| IF + Merge | Each branch does its own thing, then they rejoin into one path that carries on downstream | IF → true branch (Set A) and false branch (Set B) → Merge (Append) → Slack |
| Loop Over Items + Wait | Batching plus a pause, to stay under a rate limit | Loop Over Items → HTTP Request → Wait 1s → back to Loop Over Items |
| Split Out + processing + Aggregate | One big array from an API, something done to each entry, then the lot collapsed into one summary | HTTP Request → Split Out → processing nodes → Aggregate → Email |
| Merge, Combine · Matching Fields | Two data sources joined on a key into one complete record | HTTP A and HTTP B both into Merge (Mode = Combine, Combine By = Matching Fields, id against customer_id) → downstream |
A branch that will not fire, goes the wrong way, or refuses to split
Almost all of it comes down to four questions, asked in this order.
flowchart TD A["A branch did not do
what you expected"] --> B{"Did any item reach
the IF or Switch node?"} B -->|"no"| B1["The problem is upstream.
Read the item count on
the node before it"] B -->|"yes"| C{"Do the output counts add up
to the number that went in?"} C -->|"no"| C1["Items were dropped.
On a Switch that is
Fallback Output left on None"] C -->|"yes"| D{"Is the value a string
where you picked Number?"} D -->|"yes"| D1["Wrap Value 1 in Number,
or convert it upstream
with a Set node"] D -->|"no"| E{"Is a Merge node
downstream sitting idle?"} E -->|"yes"| E1["One input never received data.
Wire No Operation, do nothing
onto the empty branch"] E -->|"no"| F["Read the condition
row by row. Case and
whitespace count"]
| Symptom | Likely cause | How to fix it |
|---|---|---|
| “Both branches ran” | It cannot happen. IF is exclusive: each item goes true or false, never both. Both sides light up because several items came in, some going each way | Read the item count on each output. The two should add up to the upstream total |
| “It should match, yet it always goes false” | The data type does not match the operator type — the string "1500" against Number is greater than |
{{ Number($json.amount) }} or {{ String($json.status) }} in Value 1, or convert upstream with a Set node |
| “Some items disappear in the Switch” | They matched no rule and Fallback Output was never turned on | Change Fallback Output from None to Extra Output, then wire the new output to an “unclassified” Slack alert or sheet row |
| “contains does not match, though the text is plainly there” | contains is case-sensitive, or there is whitespace around the field |
{{ $json.text.toLowerCase().trim() }} in Value 1, and lowercase in Value 2 as well |
| “A date is before or after test always goes false” | Value 1 holds a date as a string, not a Date object. n8n converts it sometimes and sometimes not | {{ DateTime.fromISO($json.createdAt) }} converts it explicitly into a Luxon DateTime |
| “Expression mode says the output number is invalid” | The expression returned a number outside the Number of Outputs range — 3 outputs set, 5 returned | Give it a floor: {{ ({a:0,b:1,c:2}[$json.type]) ?? 0 }}, so anything unmatched goes to 0 |
| “The IF node opens with no Conditions block” | A version difference, or the node did not finish loading | Delete the node and add it again, or close the canvas tab and reopen the workflow |
| “Split Out runs and the output is identical” | Nine times out of ten Fields To Split Out is wrong: misspelled, or a field inside the array rather than the array, or not an array at all but an object | Read the full path off the Schema view of the upstream node and paste it in |
| “Combine · Position loses half the data” | Position aligns by index, so 3 items against 5 gives 3 — the shorter side wins and the last 2 are dropped | Use Combine By Matching Fields with Output Type Keep Everything, or Append to stack them |
| “The Merge node sits there and never runs” | Merge runs only when both inputs have data. If every condition came out false, nothing reaches the true branch and Merge waits forever | Put a No Operation, do nothing node on the empty branch, or use the fallback so something reaches both inputs |
| “Loop Over Items never stops, or runs one round and gives up” | Either the loop-branch nodes are not wired back to the node — one round runs, the rest is dropped, nothing errors — or Options → Reset is on while new items keep arriving upstream | Draw the connection back to Loop Over Items and check you did not wire it to done by mistake. Leave Reset off unless you are building a long-running service |
| “The Wait is not the pause I expected” | Wait means “this item waits N seconds before moving on”. With Batch Size 5, all five reach it at nearly the same moment and each waits its own N — not one shared pause | For 5 seconds between batches, keep Batch Size 5 and Wait 5, then read the timestamps of the requests that actually went out |
Questions people ask next
After an IF I want both paths to carry on with the original flow. How do I write that?
tag set to vip or normal. Then let the downstream nodes work from that field. The data flow stays in one piece and you keep the classification. When the two paths really do different work and you want them back on one line afterwards, that is what the Merge node is for.Do lots of IF nodes cost anything — speed, or quota?
What is the maximum number of Switch outputs? Can an IF have three?
The condition is too complex to build in the interface. What do I do?
{{ $json.amount > 1000 && ($json.tier === "vip" || $json.paid) && !$json.blocked }} fits “A AND (B OR C) AND NOT D” on one line. Readable is still better, so when it really is complicated, move it into a Code node that returns { shouldNotify: true } and let the IF test {{ $json.shouldNotify }}.Can an IF condition read data from two nodes back?
{{ $node['Node Name'].json.field }}, for example {{ $node['Webhook'].json.body.customer_id }}. This is common — the current node holds a lookup result, but you want to decide on the original payload the webhook was triggered with. Node names are case-sensitive and spaces inside them have to be kept.Can the number of branches be decided at run time?
How large can Batch Size be on Loop Over Items?
EXECUTIONS_MODE=queue) so several executions run at the same time.What if Loop Over Items fails halfway? Does the work already done roll back?
What decides a match in Combine · Matching Fields? Is it case-sensitive?
"Alice" and "alice" do not match, and types matter, so the string "123" and the number 123 do not either. When the types disagree, line them up with an Edit Fields (Set) node first, or write a CAST in SQL Query mode.Split Out or the Code node for splitting an array?
How do I use the array Aggregate produced in an expression?
name into names: {{ $json.names.join(', ') }} joins them with a comma and space, {{ $json.names.length }} counts them, and {{ $json.names.filter(n => n.startsWith('W')) }} filters.Where to go from here
A workflow that branches can fail in more than one place.
Part 12 is the other half of flow control: what happens when a node throws, how to stop one bad item killing a run of 500, and how to make a failure tell you about itself instead of going quiet.
Open the full guidePart 11 of the Woow n8n Onboarding Guide series on the Apporo blog.
Adapted from the Woow n8n Onboarding Guide, produced by WoowTech and released under CC BY 4.0. This adaptation is published by Apporo under the same licence.
Light · Air · Water · Control · apporo