The expression editor looks like a friendly place. You click a field, you toggle the =, you type $json.email, and the autocomplete even helps you. It feels like filling in a form. Then you add another node, try to reference something from three steps back, and the preview window shows nothing. Or worse, it shows the value during testing but breaks in production.
I've watched experienced engineers stare at a red error message for twenty minutes because they assumed the data was shaped one way when n8n was holding it another. The expression language isn't broken. Their mental model is.
This essay is about that model. Specifically, it's about three ideas that determine whether your workflows stay readable or become a debugging nightmare: the fact that n8n moves arrays of items, not loose JSON objects; the way expressions query a data tree; and why $json does not mean what most people think it means.
The unconscious assumption that a node passes a single JSON object to the next node. The most expensive misconception in n8n because it is invisible — you don't know you hold it until something breaks.
Here is the reality. Every node outputs a list of items. Even if you query an API that returns one user, even if your webhook receives a single event, the output is still wrapped in a list:
[
{
"json": {
"name": "Alice",
"email": "alice@example.com"
}
}
]
The list might contain one element. It might contain five hundred. But it is always a list. Each element is an item. Each item has a json property holding the actual payload, and optionally a binary property if there's file data involved. When a downstream node receives this, it processes the items independently. An action node executes its operation once per item by default. Five items in means five executions of that node's logic, producing up to five output items.
Some nodes break this pattern. Code, Merge, and Summarize receive the entire list at once and can return a different number of items than they received. But the majority of nodes — HTTP Request, Set, Slack, Postgres — follow the rule: list in, one item at a time, list out.
The single-item fallacy bites when you write an expression that treats $json as if it were the entire output of the previous node. You see one item in the editor during testing — maybe because your webhook test sent one record — and you build logic around that shape. Then the workflow goes live, the webhook fires with a batch of three events in one payload, and your expression that used $json.name suddenly behaves unpredictably.
The fix is not to learn more expression syntax. It is to look at the output panel and
see the brackets. If you see square brackets wrapping the output, you are looking at
items. If you see the json key, you are looking at one item. $json is the contents of
the json property for this specific execution run — not the list, not the wrapper.
This distinction becomes critical when you start branching. An IF node sends items down a true path or a false path, but it still sends lists. A Merge node in Append mode concatenates lists. A Split In Batches node intentionally breaks a large list into smaller lists. If you are not thinking in lists, you look at a Merge output containing eight items and wonder why your Set node ran eight times. It ran eight times because it received eight items. That is not a bug. That is the contract.
The second trap is treating expressions as if they were template variables in a static document. In a mail-merge tool, {{ first_name }} is a placeholder that gets swapped out before anything runs. In n8n, {{ $json.first_name }} is a live query against a data tree that exists only during execution.
When you write {{ $json.email }}, you are asking n8n to evaluate a JavaScript expression
against the current execution context. The expression editor is not a string substitutor.
It is a read-only query interface into a runtime tree.
This is why you can write full JavaScript inside the braces:
{{ $json.price * 1.1 }}
{{ $json.status === 'active' ? 'Yes' : 'No' }}
{{ $json.tags.join(', ') }}
{{ $now.minus({ days: 7 }).toISO() }}
It is also why multiline logic does not belong here. The expression field expects a single-line evaluation that returns a value. If you find yourself chaining ternary operators or cramming four transformations into one line, you have crossed the boundary. You aren't writing a query anymore. You are writing a script in a text field designed for field access.
Understanding expressions as queries changes how you debug them. When an expression returns undefined, the question is not "why is my variable empty?" The question is "what does my query path look like against the actual tree?"
The query model also explains why order matters. Expressions execute while the workflow is running, which means they can only query nodes that have already finished executing in this run. You cannot look forward. You cannot reference a node that sits downstream.
There are three ways to read data from upstream nodes, and most people overuse the first one because they do not see the distinction.
$json — The Implicit Parent$json is shorthand. It means: the json property of the current item, coming from the node that is directly connected to this node's input. It is the most common reference because it is the shortest:
{{ $json.email }}
{{ $json.address.city }}
{{ $json.tags[0] }}
But its convenience hides a cost. Because it is implicit, it trains you to forget where the data comes from.
The assumption that $json refers to "the node I think of as the source," rather than
the immediately preceding node according to the current execution path. The canvas
lies to you visually. The data tree does not.
Here is a concrete scenario. You have a workflow that starts with a Webhook, passes through an HTTP Request that enriches the user, and then hits a Set node. In the Set node, $json.email queries the HTTP Request's output. Later, you insert another HTTP Request between the first one and the Set node to fetch billing data. The Set node now receives billing data as its implicit parent. $json.email is suddenly undefined because the billing node does not return an email field. The expression is identical. The tree changed underneath it.
$input — The Explicit Parent$input makes the parent explicit. It offers the same data as $json, but through an object that reminds you there is a collection:
| Reference | Returns |
|---|---|
$input.item.json |
Same as $json — the current item's payload |
$input.first() |
The first item from the immediate parent |
$input.all() |
Every item from the immediate parent as a list |
I use $input.item.json when I am teaching or reviewing workflows because it is unambiguous. It forces the reader to recognise that we are inside an item-processing loop. I use $input.all() when I need to see the full output of the parent.
{{ $input.all().length }}
If you are debugging a workflow and you do not know whether the previous node output one item or twelve, $input.all() tells you immediately. It also surfaces the single-item fallacy in seconds.
$('Node Name') — Random AccessThe third pattern is direct node lookup by name:
{{ $('Customer Lookup').item.json.name }}
{{ $('HTTP Request').first().json.id }}
{{ $('Stripe Webhook').all() }}
This is the escape hatch. It lets you pull data from any upstream node regardless of the connection line you are on. It is powerful and dangerous. Powerful because you can branch logic, merge streams, and still reference the original webhook payload. Dangerous because it creates hidden dependencies. If you rename the node, the expression breaks.
Use $json for linear workflows with no branching. Use $input when you want clarity on
the parent collection. Use $('Node Name') only when you are deliberately reaching
across a branch. And if you use it, treat the node name like a variable name: never change
it without searching every expression in the workflow.
Most expression bugs are not syntax errors. The expression parser is forgiving. The bugs are structural mismatches between your query and the tree.
You write {{ $json[0].email }} because the preview panel showed brackets. But if the node feeding you is a standard action node, $json is already the object inside the first item. The [0] indexes into the object's keys, returns undefined, and your workflow silently sends empty strings downstream. The correct query is {{ $json.email }}.
After a Webhook node, the incoming HTTP body is nested under $json.body. After most other nodes, the relevant data sits directly under $json. This inconsistency trips people constantly because the Webhook is the first node they build, and they assume every node follows the same nesting. It does not. When in doubt, click the output tab and look at the keys. Do not guess.
You see undefined for {{ $json.customer.phone }}. The field exists in the source system. It exists in the API documentation. But the node upstream filtered it out, or the customer record lacks it, or it lives under {{ $json.customer.contact.phone }}. Use optional chaining to give yourself a trail:
{{ $json.customer?.phone ?? 'No phone' }}
Or better, use the Expression Editor's preview. Pin sample data on the upstream node, open the expression tab, and traverse the tree visually.
You rename a node from Get User to Enrich User to match a new requirement. Three expressions downstream still say $('Get User').item.json.id. They break immediately, and n8n does not always flag them in the UI until runtime.
Node names use PascalCase and never change once referenced in a $() expression. If
the logic changes, I duplicate the node and archive the old one rather than renaming.
Expressions support full JavaScript, which means you can write this:
{{ $json.status === 'paid' ? ($json.amount > 1000 ? 'High Value Paid' : 'Standard Paid') : ($json.status === 'pending' ? 'Awaiting' : 'Other') }}
It evaluates. It is also unmaintainable. The moment an expression needs more than one logical branch or more than two property accesses to compute a value, it belongs in a Code node.
When I am called in to fix a broken workflow, I follow a specific order:
Nine times out of ten, the bug is a mismatch between the imagined shape and the actual shape. The tenth time, it is a rename casualty.
The decision between an expression and a Code node is not about capability. It is about readability and maintainability. Anything you can write in an expression, you can write in a Code node. The reverse is not true.
Use an expression when:
Use a Code node when:
If you find yourself using the Expression Editor's multi-line preview to "test" complex logic, you have already crossed the line. Move the logic.
Open your most critical production workflow. Do not execute it. Read it.
Look at every node that uses $json in a workflow with branches, merges, or nodes
inserted after the fact. Convert ambiguous references to $input.item.json or
$('Exact Node Name').item.json so the dependency is explicit.
Click through the nodes and look at the brackets. If you see a list of items where you
expected one object, trace back to where the list was created. If a node needs the full
list, switch to $input.all() or a Code node.
Any expression longer than one readable line gets a comment in the parameter — yes, n8n lets you add parameter descriptions — or gets moved to a Code node. If it scrolls, it belongs in code.
The Expression Editor's autocomplete is only as good as the pinned data you give it. Without pinned data, you are writing queries blind.
Pick the workflow that has caused the most undefined bugs and rename its nodes to be
dependency-proof, or add notes to each field explaining the data source. The ten minutes
you spend making the tree explicit will save the next developer two hours of guessing.
Learn to see the list of items, treat expressions as queries rather than variables, and
reference your nodes deliberately. Do that, and undefined stops being a mystery and
starts being a signal that your query path needs adjusting.