Help us improve
Share bugs, ideas, or general feedback.
From n8n-skills
Guides writing and reviewing n8n expressions ({{...}} syntax), including $json/$node references, Luxon date handling, and expression error debugging.
npx claudepluginhub n8n-io/skills --plugin n8n-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/n8n-skills:n8n-expressionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
n8n's expression language is JavaScript embedded in `{{...}}` blocks. They run synchronously, on a single item at a time (`$json`), with access to upstream nodes (`$('Name')`), Luxon for dates, and most of native JS.
Validates n8n expression syntax and fixes common errors like incorrect {{}} braces, $json/$node access, webhook body referencing, and data mapping between nodes.
Validates n8n expression syntax and fixes common errors. Use when writing {{}} expressions, accessing $json/$node variables, or troubleshooting webhook data access.
Validates n8n expression syntax and fixes errors in {{}} expressions, $json/$node access, webhook data ($json.body), and common patterns for workflows.
Share bugs, ideas, or general feedback.
n8n's expression language is JavaScript embedded in {{...}} blocks. They run synchronously, on a single item at a time ($json), with access to upstream nodes ($('Name')), Luxon for dates, and most of native JS.
Reference data by node name, not $json. Use $('Node Name').item.json.field (or .first().json.field). $json works but breaks when any node clears item context (Aggregate, Code with Run for All, branching merges) or a refactor adds an intermediate. Failures are silent, and downstream gets the wrong data with no error. Node-name references are stable.
{{ DateTime.now().minus({ days: 7 }).toISO() }}. The DateTime node is more visible on the canvas for beginner human users, but avoid it unless the user specifically asks for it.$('Name').item.json.x) over $json.x$json means "the current item flowing into this node." Fine when the node is directly downstream of one source and nothing has cleared the item context (some nodes do: Aggregate, Code with Run for All, branching merges).
It breaks when:
$json.x from a node 3 steps back, and now the new intermediate node is what $json refers to).$json is whichever branch fired last, not deterministic.$('Get User').item.json.id is unambiguous. Always the named node's first-item JSON, regardless of what's between.
The exception that makes the rule:
When branches converge and you need a stable reference point, insert a NoOp node at the convergence. Name it descriptively (e.g., Combine Inputs). Downstream nodes reference it by name.
Branch A ──┐
├─→ [NoOp: Combine Inputs] ──→ Downstream nodes use $('Combine Inputs').item.json.x
Branch B ──┘
NoOp survives refactors: inserting a transform between Combine Inputs and the consumer doesn't break the $('Combine Inputs') reference.
This pattern is required when downstream nodes need data from a node whose context gets cleared by an intermediate operation.
If the branches produce different shapes, use a Set node instead of NoOp. NoOp passes through whatever shape arrived, so downstream still has to know which branch fired. A Set node normalizes both branches into one shape, and downstream reads one set of fields:
// Set node: "Normalize Inputs"
name: `={{ $('Lookup by Email').item.json.name || $('Lookup by ID').item.json.full_name }}`
email: `={{ $('Lookup by Email').item.json.email || $('Lookup by ID').item.json.contact_email }}`
Downstream nodes reference $('Normalize Inputs').item.json.name regardless of which branch produced it.
The pattern AI agents often produce:
Webhook → Set: { customer_id: $json.body.customer_id, amount: $json.body.amount }
→ Postgres: WHERE id = {{ $json.customer_id }}
→ Email: Total is {{ $json.amount }}
The Set node does nothing useful. Each downstream node could read from the webhook directly:
Webhook → Postgres: WHERE id = {{ $('Webhook').item.json.body.customer_id }}
→ Email: Total is {{ $('Webhook').item.json.body.amount }}
The Set node only earns its place if:
n8n-subworkflows.Include Other Fields: false. Set is the cleanest way to whitelist an output shape. This is the underlying mechanism behind the sub-workflow return-shaper bullet above (preventing internal scratch fields from leaking to callers), but it applies anywhere you need a clean shape downstream.For "extract a field from the request body and use it once," no Set node. The expression goes in the consuming field.
For "extract once for many downstream uses," a Set node is legitimate. If only one consumer uses it, the Set is debt (except the return-shaper case above).
How many downstream nodes reference each field?
Multiple consecutive Set nodes are almost certainly over-extraction. Collapse.
{{ $json.name.toUpperCase() }}
{{ $json.email.toLowerCase().trim() }}
{{ $json.items.length }}
{{ $json.user.first_name + ' ' + $json.user.last_name }}
{{ `(${$json.user.phone.slice(0, 3)}) ${$json.user.phone.slice(3, 6)}-${$json.user.phone.slice(6, 10)}` }}
.map(), .filter(), .find(), .reduce()Array methods are some of the most useful expression tools. They replace dozens of nodes.
{{ $json.tags.filter(tag => tag.active).map(tag => tag.name).join(', ') }}
{{ Object.values($json.scores).reduce((sum, score) => sum + score, 0) }}
// Find one matching item from another node's output
{{ $('Get Models').all().find(model => model.json.id === $json.modelId).json.modelName }}
// Filter array, then check shape
{{
$('Get User\'s Entries').all()
.map(item => item.json)
.filter(entry => entry.prize_eligible === 'eligible')
.length > 0
}}
When a chain has 2+ method calls or non-obvious filter logic, format it across lines and comment. Readers may not be the author, so comments make intent legible to non-technical readers too.
{{
// Find all entries that are still processing AFTER 1 hour
// (used to allow re-submission since something likely went wrong)
$('Get User\'s Entries').all()
.map(item => item.json)
.filter(entry =>
entry.prize_eligible === 'processing' &&
$now.diffTo(entry.created_at, 'minutes') > 60
)
.length > 0
}}
This kind of logic is common in routing nodes (Switch, IF). Un-commented, it's unreadable for most users.
.all().map() triggers an "execute once" questionWhen you use $('Source Node').all().map(...) (or .filter(), .reduce()) to process the entire dataset, the expression itself iterates. If the node has the default per-item execution mode, it runs once per input item, but each run does the full .all() aggregation: wasted work, and possibly wrong.
Set the node to execute once when:
.all().map() / .all().filter() / .all().reduce().This is executeOnce: true on the node. Most nodes have it.
const aggregateNode = node({
type: 'n8n-nodes-base.set',
config: {
executeOnce: true, // important when using .all() in expressions
parameters: {
assignments: {
assignments: [
{
name: 'totalEligible',
value: `={{
$('Get Entries').all()
.map(item => item.json)
.filter(entry => entry.eligible)
.length
}}`,
type: 'number',
},
],
},
},
},
})
Forgetting executeOnce often still works but does N times the work for N items. Worse, if downstream expects one item, you get N.
Counter-case: .all() as a per-item lookup, NOT aggregation. When the .all() reads a different node and gets filtered by the current item's identity, you want per-item execution. Each iteration produces a different result, so it's real work, not wasted.
// Workflow: Get Tags (200 items) → Search Posts (10 items) → this Set Fields node.
// Each post carries a `tag_ids` array. Set Fields runs per-item (10 times)
// and resolves each post's tag_ids into the full tag objects.
tags: ={{
$('Get Tags').all()
.filter(tag => $('Search Posts').item.json.tag_ids.includes(tag.json.id))
}}
Setting executeOnce: true here would collapse the 10 outputs to 1.
The shape distinguishing the two:
$source.all() alone (aggregating across the dataset) → executeOnce: true.$source.all().filter(... matches $other.item.json.x) (looking up by the current item) → leave executeOnce off.Quick test: does the expression use .all() without combining it with another node's .item? If yes, the node should probably be executeOnce: true.
For the broader picture on iteration and explicit looping, see the n8n-loops skill.
{{ $json.status === 'active' ? 'Active' : 'Inactive' }}
{{ $json.amount >= 100 ? 'Large' : ($json.amount >= 10 ? 'Medium' : 'Small') }}
{{ DateTime.now().toISO() }}
{{ DateTime.fromISO($json.created_at).toFormat('yyyy-MM-dd') }}
{{ DateTime.now().minus({ days: 7 }).startOf('day').toISO() }}
{{ DateTime.fromISO($json.due).diffNow('days').days }} // days from now (negative if past)
$json){{ $('Webhook Trigger').item.json.body.customer_id }}
{{ $('Lookup customer').item.json.email }}
{{ $('Combine Inputs').item.json.coupon_code }} // NoOp convergence point
.item and .first() are mostly equivalent for single-item nodes, so pick one. .first() is more explicit, .item is shorter.
When logic is too gnarly for one line but operates on a single item, wrap it in an immediately-invoked arrow function:
{{ (() => {
// Compute total including tax
const items = $json.line_items
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0)
const tax = subtotal * 0.08
return (subtotal + tax).toFixed(2)
})() }}
Inside, you get the full expression scope ($json, $('Node Name'), $now, Luxon) plus the JS you'd write in any function: const/let, if/switch, try/catch, regex.
Arguments don't work. Expressions have no caller to pass them, so (text) => text.replace(...) has nothing to invoke it with. Reference values from the outer scope directly. The function still needs the IIFE wrapping ((...)()) to actually execute.
{{ (() => $json.text.replace(/\b(?:foo|bar)\b/gi, 'baz'))() }}
The outer ( and trailing )() are mandatory: the first pair brackets the function expression, the trailing () invokes it. Drop either and n8n errors and refuses to run the workflow.
Why this over a Code node? The Code node runs in a sandboxed VM: roughly 500-1000ms worst case. The expression IIFE runs in the same context as the surrounding expression: 1-10ms consistently. For pure single-item shaping, that's a 100x gap with no functional difference. This is a common poweruser method.
A Code node still earns its place for multi-item aggregation ($input.all()), external libraries, or async work. See n8n-code-nodes for the decision tree, and n8n-code-nodes ARROW_FUNCTIONS_IN_EDIT_FIELDS.md for longer examples and formatting rules.
String, Array, Number, Object, Map, Set, JSON.parse, JSON.stringify, Math, regular expressions, Date (but only use Luxon).
{{ $json.id || "fallback-id-here" }}
Or with optional chaining:
{{ $json.user?.profile?.id ?? "anonymous" }}
Especially useful for filter values feeding queries: pass a default that matches no rows rather than letting the query fail with undefined.
Two serializers, two contexts:
.toJsonString() for compact JSON where formatting doesn't matter. Canonical case: AI prompts. Smaller, easier on tokens, easier to scan in a prompt template.
{{ $('Get Data').item.json.toJsonString() }}
JSON.stringify(value, null, 2) for pretty-printed JSON where formatting matters. Canonical case: email bodies, Slack messages, debug output, anywhere a human reads the result.
{{ JSON.stringify($('Source Node').item.json, null, 2) }}
Pick deliberately. Pretty-printing inside an LLM prompt wastes tokens and clutters the model's context. Compact JSON in an email is unreadable.
JSON.stringify and JSON.parse: where they belongJSON.stringify and JSON.parse are common in expressions. Both are fine. The key discipline: stringify and parse are storage-layer operations, not interface-layer operations.
_object-postfixed string column holding what's actually an array or object. See n8n-data-tables.JSON.parse.The classic slip: a sub-workflow has a "fresh" path (data just produced by an LLM, already an array) and a "cached" path (data just read from a _object column, still a string). The wrong instinct is to stringify the fresh path "to match" the cached one. The right instinct is to parse the cached path so both branches produce the same natural shape on the way out.
Storage representation belongs inside the workflow that owns the storage. Outside that boundary, talk in natural shapes. n8n-subworkflows SKILL.md "Return natural shapes, not storage shapes" covers this from the sub-workflow side, and n8n-data-tables covers it from the storage side.
={{ ... }}Some node fields will treat your value as a string literal unless you tell n8n to evaluate it as an expression. Wrapping in ={{ ... }} (the = prefix turns the field into expression mode) returns the actual type the inner code produces:
// String literal (default behavior)
foo: 'plain string'
// Number
foo: '={{ 100 }}'
// Boolean
foo: '={{ true }}'
// Object (the `={{ ... }}` is what makes the receiver see an object, not a string)
foo: '={{ { "valid": true, "items": [] } }}'
// Array
foo: '={{ ["a", "b", "c"] }}'
// Reference to another node's value (preserves whatever type that value already is)
foo: '={{ $("Source Node").item.json.payload }}'
When the type matters: object/array fields on Set / Edit Fields (with the column's Type set to Object or Array), JSON body parameters on HTTP Request, structured inputs to a sub-workflow's typed workflowInputs.values[type], agent tool parameters, anywhere the receiving node validates the type. Without the ={{ ... }} wrapper, you'd be passing a string and the receiver either coerces or errors.
Reference by node name, not $json, per non-negotiable #1 above:
// WRONG
foo: '={{ $json.payload }}'
// RIGHT
foo: '={{ $("Source Node").item.json.payload }}'
The exception: if $json is genuinely the right thing (no intermediate transforms, no convergence) and the field is a per-item slot on a node that's directly downstream of one source. Even then, named references are more refactor-safe.
{{
// Default to avoid query errors when user_id is missing.
// The fallback UUID is a known-empty row.
$json.id || "305f7106-6988-4651-b26a-18979641b7b5"
}}
Encouraged when logic is non-obvious. The comment will be there for the next reader.
require).$json itself is the current item only, but expressions can reach across items via $input.all(), $input.all()[3], $('Source Node').all(), etc. See "Method chains" above.
For those, see n8n-code-nodes.
Per n8n-code-nodes's decision tree:
1. Single-field transform → expression in the field
2. Multi-step pure logic on one item → arrow function in Edit Fields
3. Multi-source aggregation, libraries, or stateful → Code node
Expression is the default. Reach past it only when input or scope demands it.
Common reaches-for-extra-nodes that should stay in expressions:
| Adding this node | Better as |
|---|---|
| DateTime node to format a date | DateTime.fromISO(...).toFormat(...) in the consumer's expression |
| Set node to build an email body | Inline the expression in the email node's body field |
| Set node to compute a derived field used once | Inline at the consumer |
| Two nodes (Set + IF) to compute then test | One IF with the computation in its condition expression |
Code node to call .toUpperCase() | Just the expression |
Adding nodes for transforms means more visual clutter, slower workflows, harder reading.
When extra nodes ARE right:
| Anti-pattern | What goes wrong | Fix |
|---|---|---|
| Set node that exists to extract one field from a webhook body for one downstream consumer | Extra node for what should be inlined, fragile to refactor | Delete the Set node, reference $('Webhook').item.json.body.x directly in the consumer |
| Multiple consecutive Set nodes each defining one field | Workflow padding | Collapse. Most aren't needed, and for the ones that are, group into one Set node |
Using $json.x deep in a workflow with multiple branches and intermediate transforms | Reference breaks when an intermediate is added or context is cleared | Use $('Source Node').item.json.x. Add a NoOp convergence point if branches merge. |
| Adding a DateTime node to format a timestamp | Extra node for what's a 1-line Luxon expression | {{ DateTime.fromISO($('Source').item.json.x).toFormat('yyyy-MM-dd') }} |
| Set node to build email HTML, then read it in the Email node | Two nodes for what's one expression | Build the HTML directly in the email node's body field |
new Date($json.created_at) instead of Luxon | Loses formatting/manipulation features | DateTime.fromISO($('Source').item.json.created_at) |
| One-line expression that's actually 200 chars | Unreadable | Multi-line with arrow function, indented, with comments |
$json.foo.bar.baz without checking $json.foo exists | Crashes on missing intermediate | Use ?. chain: $('Source').item.json.foo?.bar?.baz |
| Hardcoding values in expressions that should be config | Magic strings | Use $vars.X (n8n Variables, paid plans) or a Data Table |
Branches converge with $json references downstream | Whichever branch fired last wins, non-deterministic | Insert a NoOp ("Combine Inputs") at the merge, reference by name |
Using $env.X in any expression | Doesn't work; throws at runtime | For config use $vars.X (paid plans) or a Data Table. For secrets use the credential system |