This guide gets you past the part of n8n where most people get stuck: what data actually looks like as it moves between nodes, how to pull values from earlier nodes with {{ }} expressions, how to format dates the way Taiwanese users expect, and when to reach for Edit Fields, If, Switch, Merge, Split Out and Aggregate. Only at the end do we look at when the Code node is worth opening. You need an n8n 2.x instance you can log in to, and you should have built at least one simple workflow. If you haven't, work through the first workflow tutorial first and come back; this will make a lot more sense. New to n8n altogether? Start with what n8n is.
Facts checked as of October 2026. Node names, option labels and error messages follow the n8n 2.x editor and the official documentation.
The one idea to get first: data is a list of items
Between nodes, n8n always passes an array. Each element of that array is an item, and each item is an object whose real data sits under a json key (files such as images and attachments sit under binary). If a Google Sheets node reads three order rows, the data handed to the next node looks roughly like this:
[
{ "json": { "order_id": "A1001", "customer": "Alice Wang", "amount": "1200" } },
{ "json": { "order_id": "A1002", "customer": "Ben Chen", "amount": 850 } },
{ "json": { "order_id": "A1003", "customer": "Chris Lin", "amount": "2,400" } }
]Here is the part that matters: most nodes run once for every item they receive. Feed those three items into a Gmail node and it sends three emails. Feed them into a messaging node and it sends three messages. That is deliberate, and it means you rarely write loops yourself. It also means that if you only want one summary message saying "three new orders today", you have to collapse the three items into one first (that is what Aggregate is for), or your recipient's phone will buzz three times.
The opposite situation is just as common. An HTTP Request node calls an API and gets back a single item containing an orders array. The OUTPUT panel shows 1 item, and every node after it runs once. To process each order separately you need Split Out to turn that array into separate items.
You inspect data in the node view (double-click a node). INPUT on the left shows what came in, OUTPUT on the right shows what the node produced, and both can switch between Schema, Table and JSON views, with the item count shown at the top. Get into the habit of checking the item count first. A surprising share of "why did it do that?" moments come down to the number of items being different from what you assumed.
Expression basics: what goes inside {{ }}
Every node parameter has two modes, Fixed and Expression. Hover over a field to see the toggle. In Expression mode, anything inside double curly braces {{ }} is JavaScript, and anything outside is output as plain text, so you can mix fixed text with variables:
Hi {{ $json.customer }}, your order {{ $json.order_id }} has shipped.A handful of patterns cover most real work:
| Expression | What you get |
|---|---|
{{ $json.customer }} | The customer field of the current item, from the node directly connected before this one |
{{ $json['order number'] }} | Bracket notation for field names with spaces, hyphens, or a leading digit |
{{ $json.customer.phone }} | A nested field, one dot per level |
{{ $('Webhook').item.json.body.email }} | The matching item from a specific earlier node |
{{ $('Google Sheets').first().json.customer }} | The first item that node produced; .last() gives the last one |
{{ $('Google Sheets').all().length }} | How many items that node produced |
{{ $input.all().map(i => i.json.customer).join(', ') }} | Every customer in the current input, joined into one string |
$json is shorthand for $input.item.json and only sees the node wired directly into this one. For data from three or four nodes back, name the node with $('Node Name'). The name must match the node on the canvas exactly, including capitalization and spaces.
The difference between .item and .first() matters. .item follows the pairing information n8n keeps for every item (pairedItem) back to the item that came from the same source, so each of three orders gets its own email address. .first() always returns the first item, no matter which item is currently being processed. Mixing them up is how you end up sending three emails to the same person.
Drag fields and let n8n write the expression
You don't need to memorize syntax at the start. Open the node and make sure the INPUT panel has data. If it doesn't, click Execute previous nodes so the upstream nodes run once. Then:
- Find the field in the Schema or Table view and drag its name onto a parameter on the right.
- The parameter switches to Expression mode and gets something like
{{ $json.customer }}. If the field came from a node further back, you get the$('Node Name').item.json.fieldform instead. - To use data from several nodes back, switch to or expand that upstream node in the INPUT panel and drag from there.
- Once dropped, you can keep transforming inside the braces, for example by appending
.trim().
The Edit Fields node makes this even quicker: drop an INPUT field onto the area labeled Drag input fields here inside Fields to Set, and n8n creates a new field with the same name and a matching expression.
Reading the expression preview
In Expression mode the evaluated result appears under the field as you type. The expand icon next to the field opens the full expression editor, with your expression on the left and the Result on the right. Common preview values: [undefined] means the field name is wrong or the item has no such field; [empty] means the field exists but is empty; Execute previous nodes for preview means upstream nodes haven't run, so there is nothing to evaluate. If you see [this is a function, please add ()], you forgot the parentheses, for example .first instead of .first().
One thing people miss: the preview evaluates a single item, usually the first. A clean result for item one says nothing about item five. To be sure, click Execute step and check every row in the OUTPUT Table view. Also note that the preview is computed in your browser while real executions run on the server, which becomes important with time zones.
Dates and times: $now, $today and local formats
n8n handles dates with the Luxon library. $now is the current moment and $today is midnight at the start of today; both are Luxon DateTime objects, so you can chain methods directly. Their time zone comes from, in order: the workflow's own Timezone setting, the instance-wide GENERIC_TIMEZONE environment variable, and if neither is set, the default of America/New_York. A self-hosted instance with no time zone configured will be 12 or 13 hours off Taiwan time, which is the classic beginner trap.
Here are formats commonly used in Taiwan, assuming it is 3:04 PM on October 27, 2026, Taipei time. The line after each arrow is the output:
{{ $now.toFormat('yyyy/MM/dd HH:mm') }}
-> 2026/10/27 15:04
{{ $now.toFormat('yyyy-MM-dd') }}
-> 2026-10-27 (the safest format for databases and spreadsheets)
{{ $now.setLocale('zh-TW').toFormat('cccc') }}
-> the full weekday name in Traditional Chinese
{{ ($now.year - 1911) + $now.toFormat('/MM/dd') }}
-> 115/10/27 (the Minguo calendar year used on Taiwanese forms)The format letters are Luxon tokens: yyyy is a four-digit year, MM a zero-padded month and M an unpadded one, dd and d the day, HH the 24-hour clock, and mm minutes. Lowercase m is minutes and uppercase M is months, and mixing them up is the most common mistake. cccc and ccc give long and short weekday names in whatever locale you set. The full token table is in the Luxon documentation.
Adding and subtracting time, and finding month boundaries:
{{ $today.plus(7, 'days').toFormat('yyyy/MM/dd') }}
-> 2026/11/03
{{ $today.minus(1, 'days').toFormat('yyyy-MM-dd') }}
-> 2026-10-26
{{ $today.startOf('month').toFormat('yyyy-MM-dd') }}
-> 2026-10-01
{{ $today.endOf('month').toFormat('yyyy-MM-dd') }}
-> 2026-10-31Dates from other systems usually arrive as strings, so convert them to DateTime before doing math. ISO strings such as 2026-10-27T01:30:00Z work with .toDateTime(). For formats like 2026/10/5, tell Luxon the pattern with DateTime.fromFormat():
{{ $json.created_at.toDateTime().setZone('Asia/Taipei').toFormat('yyyy/MM/dd HH:mm') }}
-> 2026/10/27 09:30 (01:30 UTC converted to Taipei time)
{{ DateTime.fromFormat($json.order_date, 'yyyy/M/d').plus({ days: 3 }).toFormat('yyyy/MM/dd') }}
-> 2026/10/08There are two ways to add time. plus(7, 'days') is an n8n shorthand available in expressions; plus({ days: 7 }) is native Luxon and works in both expressions and the Code node. Likewise, format() is an n8n addition that doesn't exist in the Code node, so using toFormat() everywhere saves you trouble. Finally, n8n converts dates to strings as they pass between nodes, so a date output by one node is text by the time the next node sees it. Call .toDateTime() again before doing arithmetic.
Strings, numbers and conditions
Since the inside of the braces is JavaScript, the native string methods work, and n8n adds a set of helpers on top. These are the ones you'll use most when cleaning up form and contact data:
{{ $json.email.trim().toLowerCase() }}
{{ $json.phone.replace(/\D/g, '') }} -> 0912-345-678 becomes 0912345678
{{ $json.address.includes('Taipei') }} -> true or false
{{ $json.message.extractEmail() }} -> pulls an email address out of text
{{ $json.email.isEmail() }} -> checks the format
{{ $json.tags.split(',') }} -> 'A,B,C' becomes an arrayBe careful with numbers. Values from webhooks, forms and Google Sheets are often strings, and in JavaScript "1200" + 100 is "1200100", not 1300. Convert first:
{{ $json.amount.toNumber() + 100 }} -> 1300 when amount is "1200"
{{ Number(String($json.amount).replace(/,/g, '')) }} -> 2400, handles "2,400"
{{ ($json.total * 1.05).round() }} -> add 5% and round
{{ $json.total.format('en-US') }} -> 1,234,567.toNumber() is a string method, so calling it on a field that is already a number fails. When you aren't sure, wrapping the value in Number() is the safe choice.
For conditions inside a single field, use the ternary operator, condition ? valueIfTrue : valueIfFalse:
{{ $json.amount > 3000 ? 'VIP' : 'Regular' }}
{{ $json.paid ? 'Paid' : 'Please pay by ' + $today.plus(3, 'days').toFormat('M/d') }}
Hi {{ $ifEmpty($json.nickname, 'there') }}
{{ $if($now.hour < 12, 'Good morning', 'Good afternoon') }}$ifEmpty(value, valueIfEmpty) falls back to the default when the value is undefined, null, an empty string, an empty array or an empty object, which is handy for optional form fields. $if() does the same job as a ternary; use whichever reads better. Once you have several conditions and several paths, stop cramming logic into expressions and use an If or Switch node.
Shaping data with nodes
Expressions compute values. Nodes change the shape of your data: which fields exist, how many paths there are, and how many items flow through each one.
| Goal | Node |
|---|---|
| Rename fields, add computed fields, keep only what you need | Edit Fields (Set) |
| Route items down two paths | If |
| Route items down three or more paths | Switch |
| Bring two paths back together, or join on a field | Merge |
| Turn an array inside one item into many items | Split Out |
| Collapse many items into one | Aggregate |
| Sort, take the first N, de-duplicate, group and total | Sort, Limit, Remove Duplicates, Summarize |
Edit Fields (Set)
This node used to be called Set; the editor now shows it as Edit Fields (Set), and searching for either "set" or "edit fields" finds it. Manual Mapping mode lets you add fields one at a time with Add Field, choosing a type (String, Number, Boolean, Array or Object) for each. JSON mode lets you write the output as JSON. The setting that confuses people is Include Other Input Fields, which is off by default. While it is off, the output contains only the fields you set here. Turn it on to keep all input fields, only selected ones, or all except some. Trimming items down to exactly the fields you need before a notification, a spreadsheet write, or an AI Agent keeps every downstream node simpler. If a Number field receives a string such as "2,400", the node throws a type error; clean the value in the expression, or enable Ignore Type Conversion Errors under Options.
If and Switch
The If node has a true output and a false output. Each condition starts with a data type and a comparison, such as Number is greater than, String contains, or Date & Time is after, and conditions can be combined with AND or OR. Every item is evaluated on its own, so both outputs can carry data in the same run. Types bite here: if amount is the string "1200" and you compare it as a Number, you get Wrong type: '1200' is a string but was expecting a number. Either append .toNumber() or turn on Convert types where required.
Switch handles three or more paths. In Rules mode each rule maps to an output. Under Options, Fallback Output decides where non-matching items go; the default, None, silently drops them, so choosing Extra Output is usually safer. Send data to all matching outputs sends an item to every rule it matches. Expression mode instead returns an output index, for example {{ $json.level === 'VIP' ? 0 : 1 }}.
Merge
Merge waits until every connected input has data. Append stacks the items from each input one after another. Combine has three variants: Matching Fields joins on a field value (think VLOOKUP or a database join, with output types such as Keep Matches or Enrich Input 1), Position pairs item 1 with item 1 and item 2 with item 2, and All Possible Combinations pairs every item with every other item. There is also SQL Query and Choose Branch. Since n8n 1.49.0 you can set Number of Inputs above two. A typical use: fetch orders and customers from two systems, then Combine by Matching Fields on the customer ID with Enrich Input 1, so every order is kept and gains the customer's details.
Split Out and Aggregate
Older tutorials mention an Item Lists node. In n8n 1.21.0 it was replaced by six separate nodes: Split Out, Aggregate, Sort, Limit, Remove Duplicates and Summarize. The official 3.0 upgrade notes list the legacy Item Lists node for removal, so replace it in old workflows now.
Split Out turns an array inside one item into separate items. Enter the array field in Fields To Split Out (for example orders, or body.orders when nested), and use Include to choose No Other Fields, All Other Fields or Selected Other Fields. Aggregate does the reverse. Individual Fields collects chosen fields into arrays; All Item Data (Into a Single List) collects whole items into one array, stored in a field called data by default. After aggregating you have one item, so the next notification node fires once:
{{ $json.notify_text.length }} orders shipped today
{{ $json.notify_text.join('\n') }}A typical flow: HTTP Request fetches orders (one item holding an array), Split Out makes one item per order, Edit Fields cleans them up, If keeps today's shipments, Aggregate collapses them, and a single notification goes out. Split, process, collapse is a rhythm you will use again and again.
The Code node: when you actually need it
Try nodes and expressions first, and open the Code node only when they get awkward. Nodes are visible on the canvas, so a teammate (or you, three months from now) can follow them without reading any code; a Code node is a black box. It earns its place when one step needs grouping, sorting and string building together, when you parse messy text with regular expressions, or when you need state across items such as running totals.
The Code node has two modes. Run Once for All Items is the default: your code runs once and reads every item via $input.all(). Run Once for Each Item runs per item and reads the current one via $json. Return an array of { json: {...} } objects; if you leave out the json wrapper, n8n adds it for you. This example totals a week of orders per customer, ranks them, and returns one item ready for a notification:
// Mode: Run Once for All Items
const totals = {};
for (const item of $input.all()) {
const name = item.json.customer || 'Unknown';
// strip thousands separators before converting
const amount = Number(String(item.json.amount).replace(/,/g, '')) || 0;
totals[name] = (totals[name] || 0) + amount;
}
const ranking = Object.entries(totals)
.sort((a, b) => b[1] - a[1])
.map(([name, total], i) => `${i + 1}. ${name} NT$${total.toLocaleString('en-US')}`);
return [{
json: {
title: 'Top customers this week (' + DateTime.now().setZone('Asia/Taipei').toFormat('M/d') + ')',
message: ranking.join('\n'),
customerCount: ranking.length,
},
}];A few things changed in n8n 2.x, so be careful when copying older snippets. Code now always runs in task runners, and environment variable access from the Code node is blocked by default (N8N_BLOCK_ENV_ACCESS_IN_NODE now defaults to true), so keep API keys in Credentials. The n8n-only helpers such as format() and plus(7, 'days') don't work in the Code node; the second one is sneaky because it doesn't throw, it just adds 7 milliseconds. Use plus({ days: 7 }) there. Helpers like $if() and $ifEmpty() are expression-only. And once a Code node creates new items, a downstream $('Webhook').item may fail to find its match; use .first() or add pairedItem to each returned item.
Common pitfalls and error messages
Can't get data for expression
This means the data an expression references doesn't exist yet, usually because an upstream node hasn't run in this test. Click Execute previous nodes or run the whole workflow. If the upstream node is a webhook or schedule, trigger it once and pin its output in the OUTPUT panel so later tests reuse it; the webhook guide covers the setup. A close relative, Node 'Check Stock' hasn't been executed, means the referenced node sits on a branch that didn't run. Guard it with {{ $if($('Check Stock').isExecuted, $('Check Stock').first().json.qty, 0) }} or rewire the flow.
pairedItem mismatches
When .item can't trace an item back, the preview shows Can't determine which item to use and executions show one of these. Paired item data for item from node 'Code' is unavailable (or Info for expression missing from previous node) means a node, typically a Code node, created items without pairing info. Multiple matching items for expression appears after Aggregate, Summarize or Merge combined items. A message saying a node is on another branch means you referenced a node on a different path. In all three cases .first(), .last() or .all()[index] works around it.
Only the first item gets processed
Check whether Execute Once is enabled in the node's Settings tab, whether your expression uses .first() or [0], and whether your data is really one item holding an array that needs Split Out. And remember the preview only shows one item.
Times that are 8 or 12 hours off
Eight hours off usually means a UTC string (ending in Z or +00:00) was formatted without .setZone('Asia/Taipei'). Twelve or thirteen hours off means the instance is running on the America/New_York default: set GENERIC_TIMEZONE=Asia/Taipei and restart, or set Timezone in the workflow's Settings (the menu in the upper-right corner of the editor). Schedule Trigger uses the same setting. Also watch for {{ 'Today is ' + $now }}, which gives a millisecond timestamp instead of a date, and for native new Date(), which ignores the workflow time zone and can evaluate differently in the browser preview than on the server.
If you'd rather not run the server
Several of these pitfalls are really server settings: time zones, environment variables, task runners, behavior changes after upgrades. If you'd rather spend your time on workflows than on version upgrades, HTTPS certificates and backups, RoamerHost's fully managed n8n runs in an isolated container with no limits on executions or workflows, a dedicated HTTPS subdomain for webhooks, the time zone preset to Asia/Taipei, automatic updates, automatic backups and Taiwan-based support, at NT$5,988 per year (avg. NT$499/mo, billed yearly). To compare with self-hosting, see what self-hosting n8n really costs.
FAQ
Q: When should I use $json and when $('Node Name').item.json?
Use $json when the data comes from the node wired directly before this one; it is the shortest and least error-prone. Use $('Webhook').item.json when you need data from further back, such as the original webhook payload after an HTTP Request, and let n8n find the matching item. If an Aggregate, Merge or Code node broke the pairing in between, switch to .first() or .all().
Q: Can I write multi-line code inside an expression?
Yes, wrap it in an immediately invoked function, for example {{ (() => { const n = $json.qty * 2; return n + 1; })() }}. Past three or four lines, though, an expression squeezed into one parameter field becomes hard to read and debug. Move the logic to a Code node, or split it across several fields in an Edit Fields node so each step is visible.
Q: Do old workflows with the Item Lists node still work?
On n8n 2.x the legacy Item Lists node still runs, but it was replaced in 1.21.0 by Split Out, Aggregate, Sort, Limit, Remove Duplicates and Summarize, and the official 3.0 upgrade notes list it for removal, after which workflows using it will fail. Swap it for the matching new node now rather than during an upgrade.
Q: Why does the preview look right but the execution fails?
Three usual causes. The preview evaluates one item, and others may be missing fields. Test runs use pinned or hand-sent data whose shape may differ from real webhook or schedule data. And the preview runs in your browser while executions run on the server, so code that ignores the workflow time zone can differ. Open the failed run on the Executions page and compare what each node actually received. Old execution data can slow n8n down; see execution data growth and cleanup.
Sources
- n8n docs: data structure, referencing previous nodes, expression reference
- n8n docs: dates and times, item linking errors
- n8n docs: Edit Fields (Set), Split Out, Aggregate, Merge, Switch
- n8n docs: Code node, 2.0 breaking changes
- Luxon: formatting tokens