Module 3: Items and n8n's Built-In Variables
5. Referencing other nodes
Description
By the end of this lesson you will be able to read the output of any node in your workflow from inside a Code node, not just the one connected to your input. You'll know the current syntax — $('Node name') — with its whole family of accesses: .all(), .first(), .last(), .item, .itemMatching(), .params, and .isExecuted. You'll understand the difference between fetching an item from another node and fetching the matching item, the distinction that separates a correct data join from one that assigns the wrong customer to each order. And you'll know how to translate the legacy $node["Name"] syntax that shows up in all material predating recent versions.
This matters because it's, in practice, the capability that most justifies opening a Code node. A visual node only sees what arrives through its input connection. Your code sees the entire workflow. When you need to cross-reference orders with a catalog fetched three steps back, or recover a piece of data lost along the way, or build a complete item by joining pieces from three different branches, the visual canvas offers you the Merge node with its limits and awkward configuration; code offers you one line.
And there's a warning worth putting up front, because it's the whole module's silliest trap: these references go by name, and the name is text. If someone renames the Get customers node to Fetch customers, every script that referenced it stops working, and n8n doesn't warn you until execution fails.
Connection to the module: lessons 3 and 4 exhausted layer 1 — what enters your node. This lesson opens layer 2: the workflow's neighborhood. It's also where pairedItem, which you studied in lesson 2, stops being theory: .item and .itemMatching() only work if the thread is intact, and the two linking errors you learned to read show up here by name. Lesson 6 moves to layer 3, the execution context, and lesson 8 uses everything from this lesson for the mini-project on joining data from three nodes.
The internal phone
Let's go back to lesson 1's dispatcher desk. The inbox tray has what the belt's previous station sent. The internal phone lets them call any other station in the center and ask what came out of there.
That's the entire idea behind $('Node name'). It isn't an internet call, it isn't a database query: it's asking another node in the same workflow what it produced in this same execution. The information is already in n8n's memory; you just ask for it.
And like any internal call, it has two conditions worth pinning down now:
Condition one: the node has to have already run. You can't ask a station that hasn't processed anything yet. If the node you're referencing comes after yours in the flow, or if it never ran in this execution, there's no data to fetch.
Condition two: you have to give its exact name. The internal phone dials by station name, not by position. Get customers and Get Customers are two different names, with the capital letter making the whole difference.
The syntax, piece by piece
Let's take apart the full expression, because every part does a job:
$('Load product catalog').all()
│ │ └──────────┬─────────┘ └─┬─┘
│ │ │ │
│ │ │ └── 3. WHAT YOU WANT from that node
│ │ │
│ │ └──────────────── 2. THE NODE'S NAME, as text
│ │
│ └────────────────────────────── 1. THE FUNCTION that fetches the node
│
└──────────────────────────────── the $ sign marks it as n8n's
Piece 1: $(). It's an n8n function that takes a name and returns an object with that node's information. The official documentation describes it this way: "Returns the data of the specified node," with syntax $(nodeName).
Piece 2: the name in quotes. It's the name you see under the node on the canvas, written exactly the same way: same capitalization, same spaces, same accents if it has any. It goes in quotes because it's a JavaScript string. You can use single or double quotes; this guide uses single ones for consistency.
$('Load product catalog') // ✅ single quotes
$("Load product catalog") // ✅ double quotes, equivalent
$(Load product catalog) // ❌ no quotes: syntax error
Piece 3: what you want from that node. This is where you choose among the seven accesses we're about to see.
A practical detail worth gold: the editor's autocomplete offers you the names of the available nodes. Type $( in the Code node's editor and n8n shows you the list of nodes it can reference. It's the way to avoid getting the name wrong, and it's faster than going to check the canvas.
The three accesses you already know
These are identical to $input's, with the only difference being they point at another node. Everything you learned in lesson 3 transfers as-is.
.all() — everything that node produced
// All the items the "Load product catalog" node produced
const catalogItems = $('Load product catalog').all();
catalogItems.length; // how many products are in the catalog
catalogItems[0].json.sku; // 'CF-ARA-500'
catalogItems[0].json.category; // 'coffee'
Returns an array of items — full envelopes, with their json inside. It's, by a huge margin, this family's most used access.
It accepts the same two optional parameters you saw in lesson 3: $('Name').all(branchIndex, runIndex). The first chooses which output to read from when the node has several — an If or a Switch; the second chooses which run, when the node executed more than once inside a Loop Over Items. The documentation clarifies that, if you don't pass branchIndex, it defaults to the output connecting that node to yours.
The official cookbook has examples worth recognizing:
// All the items the "IF" node produced through its "true" output (index 0), latest run
const trueItems = $('IF').all();
// The ones from the "false" output (index 1), from the first run (index 0)
const falseItems = $('IF').all(1, 0);
// The ones from the main output, from the same run I'm in
const sameRunItems = $('IF').all(0, $runIndex);
.first() and .last() — the first and the last
const firstProduct = $('Load product catalog').first().json;
const lastProduct = $('Load product catalog').last().json;
Same behavior as with $input: they return the item, not an array. And the same use criteria: .first() is legitimate when you know the node produces a single item — a configuration, a token, a total — and it's a silent mistake when the node produces many and you meant to get them all.
These two have a virtue that's going to matter in a moment: they don't depend on the link between items. When the pairedItem thread is broken and .item fails, .first() and .last() still work. The documentation explicitly recommends them as an emergency exit for linking errors.
The two new accesses: the matching item
And here we get to what makes this lesson special. The three previous accesses fetch an item or all the items from another node. What you almost always genuinely need is something else: that node's item corresponding to the one you're processing right now.
Think about it with Cumbre. You're processing order ORD-2044 and want the customer name an earlier node fetched. $('Get customers').first() gives you the first customer on the list, almost certainly not this order's. What you want is the customer matching this order, and n8n has two tools for that.
.item — the linked item
What it is. The documentation defines it this way: "Returns the matching item, i.e. the one used to produce the current item in the current node."
// Mode: Run Once for Each Item
// "Seed Cumbre orders"'s item that gave rise to the item I'm processing
const originalOrder = $('Seed Cumbre orders').item.json;
originalOrder.customer_name; // THIS order's customer, not another one's
How it does it. By following lesson 2's pairedItem thread. n8n maintains a backward trace for every item: for the item you're processing, that trace says which items from earlier nodes generated it. .item walks that trace until it reaches the node you asked for.
It's a property, not a method. No parentheses: $('Name').item, never $('Name').item().
When it fails. Exactly in the two cases you studied in lesson 2:
- If the thread is broken — because some intermediate Code node created new items with no
pairedItemset — you get "Info for expression missing from previous node." - If the thread leads to several items at once — because there was a
Merge, anAggregate, or aSummarizealong the way — you get "Multiple matching items for expression."
.itemMatching(i) — the matching item, said explicitly
What it is. .item's explicit version. Instead of asking "which one matches the current one?", you ask "which one matches the item at position i of my input?"
// Mode: Run Once for All Items
// "Seed Cumbre orders"'s item that gave rise to item 0 of my input
const originalOrder = $('Seed Cumbre orders').itemMatching(0).json;
Why it exists. Because in Run Once for All Items mode there's no "current item" — it's the same reason $json doesn't apply there. If you're walking an array with a loop, the loop's index is the piece of information n8n is missing, and .itemMatching(i) is where you give it to it.
The documentation is direct about this: "Use instead of $('<node-name>').item in the Code node if you need to trace back from an input item."
The operating rule, worth memorizing:
In per-item mode,
.item. In All Items mode inside a loop,.itemMatching(i).
And the same caution from lesson 2: index i is the item's position in your input, not in the output you're building.
Worked example: recovering a piece of data lost along the way
This is the canonical example, and it's adapted from the one n8n's own documentation uses.
Cumbre's scenario: the workflow carries the full orders, then an Edit Fields node trims them down to send to a system that only accepts three fields, and now, further along, you need to recover the customer email that got lost in that trim.
Seed Cumbre orders → Edit Fields: "Slim for CRM" → Code: "Restore contact info"
(9 fields) (3 fields) (4 fields)
// ============================================================
// Node: Code — "Restore contact info"
// Mode: Run Once for All Items
//
// INPUT: orders trimmed by "Slim for CRM" (order_id, customer, city)
// OUTPUT: the same ones, plus contact_email recovered from the original node
// ASSUMPTIONS: the "Seed Cumbre orders" node has already run in this execution
// and the item link is intact (nobody created new items)
// ============================================================
// --- ACT 1: read ------------------------------------------------
const items = $input.all();
// --- ACT 2: process -----------------------------------------------
const output = [];
for (let i = 0; i < items.length; i++) {
const slimOrder = items[i].json;
// The FULL order that gave rise to this trimmed item.
// I use itemMatching(i) instead of .item because I'm in All Items mode:
// there's no "current item" here, there's "the item at position i."
const fullOrder = $('Seed Cumbre orders').itemMatching(i).json;
output.push({
json: {
...slimOrder,
contact_email: fullOrder.contact_email,
channel: fullOrder.channel,
},
pairedItem: i, // keep the thread for whoever comes next
});
}
// --- ACT 3: return -------------------------------------------------
return output;
What to expect. If the Slim for CRM node let five trimmed items through, the output panel shows 5 items, each with five fields: the three it carried — order_id, customer, city — plus contact_email and channel recovered from the original node. And what matters: every email matches its own order. ORD-2041 carries Luna Coffee's email, ORD-2042 North Bakery's, and so on.
Now look at what would happen with the wrong access:
// ❌ Every item receives the FIRST customer's email
const fullOrder = $('Seed Cumbre orders').first().json;
With that line the node doesn't error out, five items with five fields each come out, and the panel looks perfect. Except all five orders carry Luna Coffee's email. It's this lesson's most expensive mistake, because it doesn't fail: it calmly delivers incorrect data, and it gets discovered when the wrong customer receives confirmation for someone else's order.
The check is simple and worth turning into a reflex: when you cross-reference data between nodes, check two or three rows of the output panel and confirm by hand that the correspondence is what you expected. Don't look only at the first one: the first one is always right, even when everything else is wrong.
The two metadata accesses
They close out the family and are less frequent, but solve concrete cases.
.isExecuted — did that node actually run?
What it is. A boolean: true if the node has already run in this execution, false if not.
// Before asking it for data, I verify the node ran
if ($('Load product catalog').isExecuted) {
const catalog = $('Load product catalog').all();
// ...
}
What it's genuinely for. For workflows with branches. If your Code node comes after an If or a Switch, some workflow nodes ran and others didn't, depending on which path the execution took. Asking a node stranded in the untaken branch for data is a mistake, and .isExecuted lets you check first.
It's a property, no parentheses.
.params — that node's configuration
What it is. An object with the parameters that node was configured with: the operation it ran, the limits, the options someone filled into its form.
// What limit did the query run with?
const queryParams = $('Get customers').params;
What it's for. Mostly for debugging, just like lesson 3's $input.params. When a node brings less data than you expected, looking at its configuration from code is faster than opening its form.
The full $() family table
| Access | What it returns | Method or property? | Depends on the link? |
|---|---|---|---|
$('N').all(branchIndex?, runIndex?) | All the items that node produced | Method | No |
$('N').first(branchIndex?, runIndex?) | The first item it produced | Method | No |
$('N').last(branchIndex?, runIndex?) | The last item it produced | Method | No |
$('N').item | The item linked to the current one | Property | Yes |
$('N').itemMatching(i) | The item linked to item i of your input | Method | Yes |
$('N').params | That node's configuration | Property | No |
$('N').isExecuted | Whether that node has already run | Property | No |
The right-hand column is the one worth keeping in mind when something fails. The five accesses that say "No" work as long as the node has run. The two that say "Yes" also need the pairedItem thread to be intact, and they're the ones producing lesson 2's two linking errors.
The legacy syntax: $node["Name"]
You're going to run into this, so it's worth knowing how to read it.
Before $() existed, n8n used another form to reference nodes, inherited from the Function and Function Item nodes the Code node replaced in version 0.198.0:
// Legacy syntax, in material predating recent versions
$node["Get customers"].json.email;
$items("Get customers");
The approximate equivalence is this:
| Legacy syntax | Current syntax |
|---|---|
$node["Name"].json | $('Name').item.json |
$items("Name") | $('Name').all() |
$item(0).$node["Name"].json | $('Name').itemMatching(0).json |
Two important warnings about that table.
First: the equivalences are approximate, not exact. The old form had default behaviors that don't always match the new one's — particularly around which item it returns when there are several. If you're translating a legacy workflow, verify the result item by item, not just that the node doesn't error out.
Second: just because the old syntax still works on your version doesn't mean it will keep working. n8n's current reference documents $() and doesn't mention $node. If you inherit code with the old form, translating it is a small investment with a clear payoff.
How to spot legacy code in thirty seconds: search your scripts for the strings $node[, $items(, and $item(. If they show up, that code was written for an earlier version, and probably has other problems from the same era — like reading secrets with $env, which is lesson 7.
The name trap
And now the warning worth the whole lesson.
$('Get customers') isn't a reference to the node: it's a text search. n8n takes that string and looks for a node in the workflow called exactly that. If it doesn't find it, it fails.
That means renaming a node breaks every reference pointing at it, in every Code node and every expression in the workflow. And n8n doesn't update them for you or warn you at the moment of renaming: the problem shows up on the next execution.
It's a real and frequent problem, especially in teams, because renaming a node feels like a harmless operation — "I gave it a clearer name" — and nobody associates that with breaking code.
Three habits that prevent it:
One: set the node's final name before writing code that references it. Sounds obvious and almost nobody does it. Name your nodes as soon as you create them, with a name that describes what they do and that you won't want to change.
Two: declare the dependency in the header comment. It's Module 1's habit applied here:
// ============================================================
// Node: Code — "Enrich orders"
// Mode: Run Once for All Items
//
// DEPENDS ON: the "Load product catalog" and "Get customers" nodes.
// If someone renames them, this script stops working.
// ============================================================
That line doesn't stop someone from renaming the node, but it makes the dependency visible to whoever opens the script, and above all to whoever is thinking about renaming it.
Three: use autocomplete instead of typing the name by hand. Type $( and choose from the list. It eliminates capitalization, spacing, and accent mistakes in one stroke.
And once it's already broken, how to diagnose it: the error usually mentions that no node with that name was found. Open the canvas, compare the name your code says against the one you see under the node, character by character, watching for capitalization and double spaces. It's a minute of work when you know what to look for, and half an hour when you don't.
Worked example: crossing orders with the catalog
Let's go to Cumbre's full case, the one you're going to build in lesson 8's mini-project.
The workflow has this shape:
Manual Trigger
└─► Code: "Load product catalog" ← product catalog (4 SKUs)
└─► Code: "Seed Cumbre orders" ← the 5 sample orders
└─► Code: "Enrich with catalog"
The first two Code nodes are seed nodes: they ignore their input and return fixed data, like the one you built in Module 1. In a real workflow they'd be a Google Sheets and a Webhook, but for practice the seed is faster and depends on nothing external.
The catalog:
// ============================================================
// Node: Code — "Load product catalog"
// Mode: Run Once for All Items
//
// INPUT: none (ignores whatever arrives from the trigger)
// OUTPUT: 4 products from Cumbre's catalog
// ============================================================
const products = [
{ sku: 'CF-ARA-500', product_name: 'Arabica Coffee 500g', category: 'coffee', weight_grams: 500, supplier: 'Sierra Verde' },
{ sku: 'CF-ROB-1000', product_name: 'Robusta Coffee 1kg', category: 'coffee', weight_grams: 1000, supplier: 'Sierra Verde' },
{ sku: 'TE-CHM-100', product_name: 'Chamomile Tea 100g', category: 'tea', weight_grams: 100, supplier: 'Casa Herbal' },
{ sku: 'TE-MNT-100', product_name: 'Mint Tea 100g', category: 'tea', weight_grams: 100, supplier: 'Casa Herbal' },
];
return products.map((product) => ({ json: product }));
And the node that crosses them:
// ============================================================
// Node: Code — "Enrich with catalog"
// Mode: Run Once for All Items
//
// INPUT: Cumbre orders with line_items (sku, quantity, unit_price)
// OUTPUT: the same orders, with each line enriched with the
// category, weight, and supplier from the catalog, plus
// the order's total weight
// DEPENDS ON: the "Load product catalog" node
// ASSUMPTIONS: there might be SKUs in an order that aren't in the catalog
// ============================================================
// --- ACT 1: read ------------------------------------------------
const items = $input.all();
const catalogItems = $('Load product catalog').all();
// I build an index by SKU: looking things up in an object is direct,
// while walking the catalog inside the loop would repeat the same
// work once per order line.
const productBySku = {};
for (const item of catalogItems) {
productBySku[item.json.sku] = item.json;
}
// --- ACT 2: process -----------------------------------------------
const output = [];
for (let i = 0; i < items.length; i++) {
const order = items[i].json;
const lines = order.line_items || [];
let totalWeight = 0;
const enrichedLines = [];
for (const line of lines) {
// Guard: a SKU in the order might not exist in the catalog.
// Orders from the rep_csv channel bring misspelled codes.
const product = productBySku[line.sku];
if (!product) {
enrichedLines.push({
...line,
category: 'unknown',
catalog_match: false,
});
continue;
}
totalWeight = totalWeight + product.weight_grams * line.quantity;
enrichedLines.push({
...line,
category: product.category,
supplier: product.supplier,
catalog_match: true,
});
}
output.push({
json: {
...order,
line_items: enrichedLines,
total_weight_grams: totalWeight,
},
pairedItem: i,
});
}
// --- ACT 3: return -------------------------------------------------
return output;
What to expect. 5 orders come in and 5 come out. For ORD-2041 — 12 units of 500-gram CF-ARA-500 and 6 of 100-gram TE-CHM-100 — the total_weight_grams field equals 12 × 500 + 6 × 100 = 6600. Every order line now carries three new fields: category, supplier, and catalog_match.
For ORD-2045, which has no lines, total_weight_grams equals 0 and line_items comes out as an empty list. It doesn't fail, thanks to the order.line_items || [] guard.
Three of the script's decisions worth flagging:
One: an index was built. The catalog has 4 products and the orders have few lines, so the speed difference is imperceptible. With a catalog of 3,000 products and a batch of 500 orders, it wouldn't be. It's a decision that costs nothing to get right from the start.
Two: .all() was used, not .itemMatching(). There's no one-to-one correspondence between orders and products here: any order can contain any product. The relationship is resolved by SKU, a business fact, not by n8n's item linking. That distinction matters: .itemMatching() is for when the correspondence is "this item came from that one"; when the correspondence is "this order mentions that product," your own logic resolves it with a key.
Three: there's a guard for the unknown SKU. Without it, product.weight_grams on an undefined would crash the whole node. With it, the line comes out flagged catalog_match: false and the problem stays visible in the data instead of taking down the run. It's defensive code, and Module 6 formalizes it.
Common mistakes
Using .first() where .item or .itemMatching() should have gone (practical). What happens: you cross-reference two nodes and every output item receives the same data from the referenced node's first item. The node doesn't error out, the item count is correct, and the data is wrong. Why it happens: .first() is the first thing you run into exploring the family and it works "fine" while testing with a single sample item. How to spot it: look at the second and third rows of the output panel, never just the first; if the crossed data is identical across all of them, this is it. How to fix it: .item in per-item mode, .itemMatching(i) in All Items mode inside a loop. And if the correspondence isn't by item linking but by a business key — a SKU, a customer identifier — the answer is .all() plus an index you build yourself.
Referencing a node that didn't run (practical). What happens: your Code node is on the If's "false" branch and references a node that only runs on the "true" branch. Execution fails, or returns empty data. Why it happens: $() reads what a node produced in this execution; if the node ended up in the untaken path, it produced nothing. How to spot it: look at the canvas after the run and check which nodes are marked as executed. How to fix it: check with .isExecuted before asking for data, or redesign the workflow so the data travels inside the items instead of being fetched from elsewhere.
Typing the node's name by hand and getting it wrong (practical). What happens: $('Get Customers') capitalized, when the node is called Get customers. The error talks about a nonexistent node, and you swear it exists because you're looking right at it on the canvas. Why it happens: the reference is an exact text comparison, and the human eye doesn't catch a capital letter or a double space. How to spot it: copy the name from the code and paste it over the node's name on the canvas; if they don't look identical, there it is. How to fix it: use autocomplete — type $( and choose from the list — and eliminate the entire class of mistake.
Renaming a node without checking who references it (conceptual). What happens: someone on the team improves a node's name, saves, and three Code nodes stop working on the next production run. Why it happens: renaming feels harmless and n8n doesn't update the text references inside the code. How to spot it: before renaming, search for the old name across every Code node and expression in the workflow. How to fix it: declare dependencies in each script's header comment and adopt the team rule that node names are part of the contract, not decoration. If the rename is necessary, update every reference in the same change.
Ignoring the linking error and "fixing" it with .first() (conceptual). What happens: "Info for expression missing from previous node" shows up, someone swaps .item for .first(), the error goes away, and the workflow moves on. And from that moment it silently delivers incorrect data. Why it happens: .first() doesn't depend on the link, so it turns off the symptom without touching the cause. How to spot it: if you swapped .item for .first() and the batch has more than one item, you almost certainly broke the correspondence. How to fix it: the real fix is setting pairedItem in the Code node that cut the thread, as you saw in lesson 2. .first() is a legitimate remedy only when the referenced node genuinely produces a single item.
Translating legacy syntax without verifying the data (practical). What happens: $node["Get customers"].json gets mechanically replaced with $('Get customers').item.json, the node doesn't error, and the correspondence between items changed without anyone noticing. Why it happens: the equivalences between old and new syntax are approximate, especially around which item they return when there are several. How to spot it: after translating, compare two or three output rows before and after the change. How to fix it: translate one node at a time, run it, and verify the correspondence by hand with small data. It's slower and it's the only way not to introduce a silent bug while "modernizing" the code.
Exercises
Exercise 1 — Choose the correct access. For each Cumbre situation, say which $('Node name') access you'd use and why. The Code node is in Run Once for All Items mode unless stated otherwise.
(a) Fetch the full product catalog the Load product catalog node left.
(b) Fetch the run's token, which the Build config node left in a single item.
(c) Per-item mode: recover the original order's contact_email, lost in an intermediate trim.
(d) All Items mode, inside a loop: the same as (c).
(e) Find out whether the Fetch premium prices node, which sits on a conditional branch, actually ran.
(f) See what results limit the Get customers node ran with.
See solution
(a) .all(). You want the whole set, and there's no one-to-one correspondence between orders and products: your own logic resolves the relationship by SKU.
(b) .first(). It's the legitimate case: you know the node produces a single item with information for the whole batch. Write it in the header comment so it shows it isn't an oversight.
(c) .item. In per-item mode "the current item" exists, and .item follows the pairedItem thread to that node's item that generated it.
(d) .itemMatching(i), with i as the loop's index over your own input. In All Items mode there's no current item, so n8n needs to be told which one to trace from.
(e) .isExecuted. It's exactly for this, and it's the correct way to protect yourself before asking for data from a node that might have ended up in the untaken branch.
(f) .params. It gives you that node's configuration with no need to open its form.
Why this works: notice (c) and (d) are the same need solved with two different accesses, and the only thing that changes is the node's mode. That's the rule worth taking away: .item belongs to per-item mode, .itemMatching(i) belongs to All Items mode inside a loop.
Exercise 2 — Find the incorrect crossover. This Cumbre script means to add each order the customer tier an earlier node fetched. It has a mistake that produces no message and does produce incorrect data. Find it, explain the exact symptom, and write the corrected version.
// Node: Code — "Add customer tier"
// Mode: Run Once for All Items
const output = [];
for (const item of $input.all()) {
const order = item.json;
const customer = $('Get customers').first().json;
output.push({
json: {
...order,
customer_tier: customer.tier,
credit_terms: customer.credit_terms,
},
});
}
return output;
See solution
The mistake is .first(). It always returns Get customers's first customer, whichever order is being processed. All five orders come out with the same customer_tier and the same credit_terms, which are the first customer's on the list.
The exact symptom: the node runs green, the output item count is correct — 5 in, 5 out — every field is present and holds plausible values. Nothing indicates a problem. It gets discovered when someone notices Andes Deli, a bronze customer, showing up with gold-customer credit terms.
And there's an underlying problem before the fix: you have to decide how an order corresponds to a customer. There are two possible answers, and they're different:
Answer A — by item link. If the Get customers node produced one item per order, in the same order, the correspondence is the pairedItem thread's:
const items = $input.all();
const output = [];
for (let i = 0; i < items.length; i++) {
const order = items[i].json;
const customer = $('Get customers').itemMatching(i).json;
output.push({
json: {
...order,
customer_tier: customer.tier,
credit_terms: customer.credit_terms,
},
pairedItem: i,
});
}
return output;
Answer B — by business key. If Get customers fetched the full list of Cumbre's 400 customers, item linking says nothing: the correspondence is given by customer_id.
// ============================================================
// Node: Code — "Add customer tier"
// Mode: Run Once for All Items
//
// INPUT: Cumbre orders with customer_id
// OUTPUT: the same ones, with customer_tier and credit_terms
// DEPENDS ON: the "Get customers" node, which returns the full list
// ASSUMPTIONS: there might be orders whose customer_id isn't in the list
// ============================================================
const items = $input.all();
// Index by customer_id: the correspondence is a business one, not an item link
const customerById = {};
for (const item of $('Get customers').all()) {
customerById[item.json.customer_id] = item.json;
}
const output = [];
for (let i = 0; i < items.length; i++) {
const order = items[i].json;
const customer = customerById[order.customer_id];
output.push({
json: {
...order,
customer_tier: customer ? customer.tier : 'unknown',
credit_terms: customer ? customer.credit_terms : null,
customer_match: Boolean(customer),
},
pairedItem: i,
});
}
return output;
Which one is correct depends on the workflow, and that's the lesson. Answer B is more robust — it doesn't depend on order or on the link, and it survives someone inserting a node in the middle — and it's usually the right one when the referenced node brings a full catalog or master list. Answer A is correct when the referenced node produced exactly one item per one of yours.
Why this works: .first()'s mistake is easy to fix; the hard part is the question it forces you to ask. What relates this item to that one: n8n's thread or a key in my own data? Answering it correctly is the difference between a crossover that works and one that works today.
Exercise 3 — Translate and verify a legacy script. You're handed this Code node, which worked on an old n8n instance. Translate it to the current syntax, then answer: what three things would you verify before considering the translation good?
// Node: Code — "Merge order with customer" (legacy, written in 2023)
const orders = $items("Seed Cumbre orders");
const output = [];
for (let i = 0; i < orders.length; i++) {
const customer = $node["Get customers"].json;
output.push({
json: {
order_id: orders[i].json.order_id,
customer_name: customer.name,
customer_tier: customer.tier,
},
});
}
return output;
See solution
The translation:
// ============================================================
// Node: Code — "Merge order with customer"
// Mode: Run Once for All Items
//
// INPUT: ignored; orders are read from the source node
// OUTPUT: one item per order, with order_id, customer_name, and customer_tier
// DEPENDS ON: the "Seed Cumbre orders" and "Get customers" nodes
// ============================================================
const orders = $('Seed Cumbre orders').all();
const output = [];
for (let i = 0; i < orders.length; i++) {
const customer = $('Get customers').itemMatching(i).json;
output.push({
json: {
order_id: orders[i].json.order_id,
customer_name: customer.name,
customer_tier: customer.tier,
},
pairedItem: i,
});
}
return output;
The three things I'd verify before considering it good:
One: which item $node["Get customers"].json returned in the original version. This is the most delicate translation of all. The old form had a default behavior for which item to take when the node had several, and it doesn't necessarily match what .itemMatching(i) does. It's entirely possible the original script was always returning the first customer — the same bug as exercise 2 — and nobody had noticed. The translation shouldn't preserve the original's mistakes. You have to decide what the correct correspondence is and write it, even if that changes behavior.
Two: whether the correspondence is by link or by key. The same question from the previous exercise. If Get customers brings the full customer list, .itemMatching(i) is the wrong translation even though it doesn't error out: the correct one would be an index by customer_id.
Three: whether the script has other problems from its era. A 2023 script using $node[ is a good candidate to also have $env for reading secrets — which no longer works in 2.0, lesson 7's subject — or $evaluateExpression() — which doesn't either. It's worth reading the whole file before considering the migration closed.
And an observation about the script itself, beyond syntax: this Code node ignores its own input. It reads the orders from $('Seed Cumbre orders') instead of $input.all(). It's valid, but it's a design signal worth reviewing: if the node is connected directly after the seed, $input.all() would say the same thing and be more robust, because it doesn't depend on another node's name.
Why this works: migrating legacy code isn't a mechanical search-and-replace operation. It's a review, and its most valuable part is asking whether the original was doing the right thing. A mistake that's been in production for three years is still a mistake, and a migration is the best opportunity to find it.
Summary and next step
In this lesson you opened up layer 2: the workflow's neighborhood. $('Node name') is the dispatcher's internal phone, and it lets you read the output of any workflow node that's already run, regardless of whether it's connected to your input or four steps back.
Its family has seven accesses, grouped into three blocks. The three you already knew from $input — .all(), .first(), and .last(), with their optional branchIndex and runIndex parameters — bring the set or a single item and don't depend on the link between items. The two new ones — .item and .itemMatching(i) — bring the matching item by following the pairedItem thread, and they're what you need when crossing data: .item in per-item mode, .itemMatching(i) in All Items mode inside a loop, which is what the documentation recommends for the Code node. And the two metadata ones — .isExecuted and .params — protect you from referencing a node stranded on an untaken branch and help you debug.
The lesson's most expensive mistake is using .first() where .item or .itemMatching(i) should have gone: it doesn't fail, doesn't warn, and assigns every item the first one's data. The check is simple and worth turning into a reflex: when crossing data, check two or three rows of the output panel, never just the first.
You also saw that the correspondence between items isn't always something n8n resolves. When the relationship is "this item came from that one," the pairedItem thread is the answer. When the relationship is "this order mentions that product" or "this order belongs to that customer," your own logic resolves it with a business key and an index you build yourself, which is also more robust because it doesn't depend on order.
And two operational warnings remain. The legacy syntax $node["Name"] and $items("Name") shows up in almost all material predating recent versions; its equivalences with $() are approximate, and you have to verify the data after translating, not just that the node doesn't fail. And the name trap: the reference is an exact text search, so renaming a node breaks everything that referenced it, with no warning. The three remedies are naming nodes before writing code, declaring dependencies in the header comment, and using autocomplete by typing $(.
Before moving on you should be able to: write from memory the difference between .first(), .item, and .itemMatching(i); say which of the seven accesses depend on the item link; and explain why renaming a node is a dangerous operation in a workflow with Code nodes.
With this you've closed out layers 1 and 2: you know how to read what enters your node and what any other node produced. Lesson 6 climbs to layer 3, the execution context: everything that doesn't come with any data. You're going to see $now and $today, which aren't text strings but Luxon objects with built-in date arithmetic; $execution, which tells you the run's identifier and — very useful — whether it's your own test or a production run; $workflow, $runIndex, $itemIndex, and $prevNode; and $vars, the path by which project configuration reaches your code without you having to hand-write it. That last one directly sets up lesson 7, the module's most important one.
Resources
- Reference previous nodes — n8n Docs — the official table of
$()'s seven accesses with the Code node availability column and the recommendation to useitemMatching()instead of.itemfrom code. - Node output data reference — n8n Docs — each access's spec sheet with its exact syntax and parameters.
- Accessing linked items in the Code node — n8n Docs — the official example of
itemMatching()recovering data lost in an intermediate trim. - Item linking errors — n8n Docs — the two errors
.itemand.itemMatching()produce when the thread is broken or ambiguous, and why.first(),.last(), and.all()[index]are the emergency exit. - (node-name).all — n8n Docs — cookbook examples with
branchIndexandrunIndexapplied to anIfnode. - Root-level variables — n8n Docs —
$()'s spec sheet and the absence of$node, confirming which syntax is current. - Merge node — n8n Docs — the visual alternative for joining data from two branches, worth keeping in mind before writing the crossover by hand.