Validate n8n expression syntax and fix common errors. Use when writing n8n expressions, using {{}} syntax, accessing $json/$node variables, troubleshooting expression errors, mapping data between nodes, or referencing webhook data in workflows. Use this skill whenever configuring node fields that reference data from previous nodes — expressions are how n8n passes data between nodes, and getting the syntax wrong is the most common source of workflow errors. Also use when asked whether a complex expression hurts performance.
77%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./skills/n8n-expression-syntax/SKILL.mdExpert guide for writing correct n8n expressions in workflows.
All dynamic content in n8n uses double curly braces:
{{expression}}Examples:
✅ {{$json.email}}
✅ {{$json.body.name}}
✅ {{$node["HTTP Request"].json.data}}
❌ $json.email (no braces - treated as literal text)
❌ {$json.email} (single braces - invalid)Access data from the current node:
{{$json.fieldName}}
{{$json['field with spaces']}}
{{$json.nested.property}}
{{$json.items[0].name}}Access data from any previous node:
{{$node["Node Name"].json.fieldName}}
{{$node["HTTP Request"].json.data}}
{{$node["Webhook"].json.body.email}}Important:
Access current date/time:
{{$now}}
{{$now.toFormat('yyyy-MM-dd')}}
{{$now.toFormat('HH:mm:ss')}}
{{$now.plus({days: 7})}}Access environment variables:
{{$env.API_KEY}}
{{$env.DATABASE_URL}}Warning: Some n8n instances have N8N_BLOCK_ENV_ACCESS_IN_NODE enabled, which blocks $env access entirely. If $env returns errors, use alternative approaches:
Most Common Mistake: Webhook data is NOT at the root!
{
"headers": {...},
"params": {...},
"query": {...},
"body": { // ⚠️ USER DATA IS HERE!
"name": "John",
"email": "john@example.com",
"message": "Hello"
}
}❌ WRONG: {{$json.name}}
❌ WRONG: {{$json.email}}
✅ CORRECT: {{$json.body.name}}
✅ CORRECT: {{$json.body.email}}
✅ CORRECT: {{$json.body.message}}Why: Webhook node wraps incoming data under .body property to preserve headers, params, and query parameters.
// Simple nesting
{{$json.user.email}}
// Array access
{{$json.data[0].name}}
{{$json.items[0].id}}
// Bracket notation for spaces
{{$json['field name']}}
{{$json['user data']['first name']}}// Node without spaces
{{$node["Set"].json.value}}
// Node with spaces (common!)
{{$node["HTTP Request"].json.data}}
{{$node["Respond to Webhook"].json.message}}
// Webhook node
{{$node["Webhook"].json.body.email}}// Concatenation (automatic)
Hello {{$json.body.name}}!
// In URLs
https://api.example.com/users/{{$json.body.user_id}}
// In object properties
{
"name": "={{$json.body.name}}",
"email": "={{$json.body.email}}"
}Code nodes use direct JavaScript access, NOT expressions!
// ❌ WRONG in Code node
const email = '={{$json.email}}';
const name = '{{$json.body.name}}';
// ✅ CORRECT in Code node
const email = $json.email;
const name = $json.body.name;
// Or using Code node API
const email = $input.item.json.email;
const allItems = $input.all();// ❌ WRONG
path: "{{$json.user_id}}/webhook"
// ✅ CORRECT
path: "user-webhook" // Static paths only// ❌ WRONG
apiKey: "={{$env.API_KEY}}"
// ✅ CORRECT
Use n8n credential system, not expressionsBefore you add a node — or write any code — to transform data, walk this order and stop at the first that fits:
using-n8n-mcp-skills maps jobs to nodes; their configs are in n8n-node-configuration → NATIVE_NODES.md.{{ ... }}) inside that node's field. Property access, method chains
(.map().filter().join()), a ternary, string building, Luxon date math, the helper functions below — "take A,
produce B" in one expression, without intermediate variables.
$jmespath() in that same expression
(rules below). When downstream needs one item per element, use Split Out / Filter / Aggregate instead.Why Code: note. See n8n-code-javascript.A function body is code. When the logic needs const, if, return or several statements — an
(() => { ... })() wrapped in {{ }} — it has outgrown an expression. Split it into a native node plus short
expressions, or move it into a Code node with a Why Code: note. A long expression is harder to read than either, and a
runtime error inside it resolves silently to null (see Debugging). The create/update hooks flag function bodies in
non-Code nodes.
Why the order matters. Readability: a native node says on the canvas what it does, and a short expression stays
visible in the field that uses it, instead of hiding in an upstream node someone has to open. Performance rarely decides
it — below a few hundred items none of the costs matter, and the measured model lives in n8n-code-javascript.
$jmespath() — query nested JSON in one expression{{ $jmespath($json, "customers[?country=='PL' && revenue > `100000`].name") }} → ["Acme"]Verified on n8n 2.38: this one expression returns exactly what Split Out → Filter → Aggregate returns, with no extra nodes. The syntax is unforgiving, and most mistakes fail silently:
| Write | Not | What the wrong form does |
|---|---|---|
$jmespath(object, "query"), object first | $jmespath("query", object) | throws expected two arguments (Object, string) for this function (JMESPath's own docs show search(query, data)) |
strings in single quotes: country=='PL' | country=="PL" | double quotes mean a field name → returns [], no error |
numbers/booleans in backticks: revenue > `100000` | revenue > 100000 | parse error → whole expression becomes null (see Debugging) |
&& || ! == | and or = | parse error → null |
hyphenated keys quoted: 'customers[*].contact."first-name"' (single-quote the JS string) | contact.first-name | parse error → null |
over items, keep the wrapper: $jmespath($('Node').all(), "[?json.country=='PL'].json.name") or $jmespath($input.all().map(i => i.json), "[?country=='PL'].name") | "[?country=='PL'].name" on .all() | items are {json: …} wrappers → [] |
null; filter with no match → []; sum() over an empty
projection → 0.undefined ($json.missingField) throws the same expected two arguments error.
That one does fail the node.length(), sum(), max_by(arr, &field), sort_by(arr, &field),
reverse(), contains(), starts_with(), keys(), to_number(); projection [*], flatten
[], pipe | [0], reshape {name: name, email: contact.email}.$jmespath in
Edit Fields and follow with a single Split Out on that field.n8n adds methods to values inside {{ }}. Together with a native node they replace most one-line Code nodes. They
exist only in expressions, not in Code nodes — inside Code use plain JavaScript (and toFormat() instead of format()).
They see only the current item's values: jobs across items (dedupe items, group and total, join two lists) belong in a
node. On n8n-mcp 2.97.0+, tools_documentation({topic: "expression_helpers", depth: "full"}) lists every helper with a
tested example.
| Need | Expression | Note |
|---|---|---|
| One field from every object | {{ $json.items.pluck('name') }} | several names → one array per object |
| Sum / average / min / max | {{ $json.prices.sum() }} | throws on non-numbers, numeric strings included |
| Dedupe an array | {{ $json.rows.unique('email') }} | each argument is one field name; none → compares whole values (removeDuplicates is an alias) |
| Default for an empty value | {{ $ifEmpty($json.name, 'there') }} | empty = undefined, null, '', [], {}; exactly two arguments |
| Email or domain in text | {{ $json.text.extractEmail() }}, .extractDomain() | undefined when nothing matches |
| Hash a string | {{ $json.email.hash('sha256') }} | md5 by default; HMAC needs the Crypto node |
| Days between dates | {{ $now.diffTo($json.created_at, 'days') }} | receiver minus argument: positive when the argument is earlier |
| String to date | {{ $json.created_at.toDateTime().format('yyyy-MM-dd') }} | then .diffTo(), .plus(7, 'days'), .extract('month') |
| Split an array into batches | {{ $json.ids.chunk(100) }} | for bulk API bodies |
| Drop empty values | {{ $json.tags.compact() }}, {{ $json.compact() }} | arrays and objects |
diffTo takes a DateTime, a JS Date or an ISO string, and an optional unit (default days) or a list of units (then it
returns an object). $json.created_at.toDateTime().diffToNow('days') is negative for past dates..sum() over strings throws: {{ $json.items.pluck('price').map(Number).sum() }}.null or undefined throws: guard with ?. ({{ $json.email?.extractDomain() }}) or $ifEmpty().{{ }} returns null silently. Test with real items either way.A Set / Edit Fields node whose only job is to copy or rename fields so the next node can read them is dead weight. Inline its expressions at the consumer instead.
❌ Webhook → Set { customer_id: {{ $json.body.customer_id }} } → Postgres: WHERE id = {{ $json.customer_id }}
✅ Webhook → Postgres: WHERE id = {{ $('Webhook').item.json.body.customer_id }}The Set node adds a hop, more canvas clutter, and a refactor hazard, while doing nothing the consumer couldn't do itself. To remove it cleanly with n8n_update_partial_workflow: rewire the connection (removeConnection from the Set's source-and-target, addConnection straight from source to consumer), patchNodeField the consumer's expression to reference the original source by node name, then removeNode the Set.
Quick test: does the Set only re-map fields the next node could read directly ($('Source').item.json.x)?
Edit Fields earns its place when:
{ error: 'validation_error', message: … } before a Respond, or a sub-workflow's output.Include Other Fields: false it whitelists the output shape so internal scratch fields don't leak.When branches converge (after IF/Switch/Merge), $json becomes "whichever branch fired last" — non-deterministic, and a silent source of wrong data. Insert a NoOp node at the convergence, name it descriptively (Combine Inputs), and have downstream nodes reference it by name:
Branch A ──┐
├─→ [NoOp: Combine Inputs] ──→ downstream uses $('Combine Inputs').item.json.x
Branch B ──┘The NoOp survives refactors: inserting a transform later between it and the consumer doesn't break the $('Combine Inputs') reference. (If the branches produce different shapes, use a Set node instead of a NoOp to normalize both into one shape — see the exceptions above.)
More broadly in branchy flows, prefer $('Node').item.json.x over deep $json.x. $json breaks the moment an intermediate node is inserted or a node clears item context (Aggregate, Code with Run for All, branching merges); the failure is silent and downstream gets the wrong data with no error. A node-name reference is unambiguous regardless of what sits between source and consumer.
Expressions must be wrapped in double curly braces.
❌ $json.field
✅ {{$json.field}}Field or node names with spaces, diacritics, or special characters require bracket notation:
❌ {{$json.field name}}
✅ {{$json['field name']}}
❌ {{$node.HTTP Request.json}}
✅ {{$node["HTTP Request"].json}}
// Bracket notation is mandatory for keys with special characters
✅ {{$json['Gross Price w/o shipment']}}
✅ {{$json['Cena brutto zł']}}Node references are case-sensitive:
❌ {{$node["http request"].json}} // lowercase
❌ {{$node["Http Request"].json}} // wrong case
✅ {{$node["HTTP Request"].json}} // exact matchDon't double-wrap expressions:
❌ {{{$json.field}}}
✅ {{$json.field}}For complete error catalog with fixes, see COMMON_MISTAKES.md
| Mistake | Fix |
|---|---|
$json.field | {{$json.field}} |
{{$json.field name}} | {{$json['field name']}} |
{{$node.HTTP Request}} | {{$node["HTTP Request"]}} |
{{{$json.field}}} | {{$json.field}} |
{{$json.name}} (webhook) | {{$json.body.name}} |
'={{$json.email}}' (Code node) | $json.email |
For real workflow examples, see EXAMPLES.md
Webhook receives:
{
"body": {
"name": "John Doe",
"email": "john@example.com",
"message": "Hello!"
}
}In Slack node text field:
New form submission!
Name: {{$json.body.name}}
Email: {{$json.body.email}}
Message: {{$json.body.message}}HTTP Request returns:
{
"data": {
"items": [
{"name": "Product 1", "price": 29.99}
]
}
}In Email node (reference HTTP Request):
Product: {{$node["HTTP Request"].json.data.items[0].name}}
Price: ${{$node["HTTP Request"].json.data.items[0].price}}// Current date
{{$now.toFormat('yyyy-MM-dd')}}
// Result: 2025-10-20
// Time
{{$now.toFormat('HH:mm:ss')}}
// Result: 14:30:45
// Full datetime
{{$now.toFormat('yyyy-MM-dd HH:mm')}}
// Result: 2025-10-20 14:30// First item
{{$json.users[0].email}}
// Array length
{{$json.users.length}}
// Last item
{{$json.users[$json.users.length - 1].name}}// Dot notation (no spaces)
{{$json.user.email}}
// Bracket notation (with spaces or dynamic)
{{$json['user data'].email}}// Concatenation (automatic)
Hello {{$json.name}}!
// String methods
{{$json.email.toLowerCase()}}
{{$json.name.toUpperCase()}}// Direct use
{{$json.price}}
// Math operations
{{$json.price * 1.1}} // Add 10%
{{$json.quantity + 5}}// Ternary operator
{{$json.status === 'active' ? 'Active User' : 'Inactive User'}}
// Default values
{{$json.email || 'no-email@example.com'}}// Add days
{{$now.plus({days: 7}).toFormat('yyyy-MM-dd')}}
// Subtract hours
{{$now.minus({hours: 24}).toISO()}}
// Set specific date
{{DateTime.fromISO('2025-12-25').toFormat('MMMM dd, yyyy')}}// Substring
{{$json.email.substring(0, 5)}}
// Replace
{{$json.message.replace('old', 'new')}}
// Split and join
{{$json.tags.split(',').join(', ')}}A common worry is that a complex {{ }} is slow. It isn't — what costs is how many times n8n evaluates an
expression, not how elaborate each one is. Measured on n8n 2.x, an elaborate expression (sqrt, split, reduce)
costs about the same per item as {{ $json.x > 50 }}: most of the cost is n8n building the per-item evaluation context.
What this means in practice:
nullVerified on n8n 2.38 with the default expression runtime: during execution the handler around
each {{ }} re-throws only n8n's own ExpressionErrors and swallows other JavaScript errors, so
the field resolves to empty/null and the node still reports success. $json.missing.field
(TypeError), JSON.parse('{bad'), throw new Error(...) and JMESPath syntax errors all
produced null. In a Filter or IF condition every item then silently fails the check. On other
versions or expression engines the same mistake may fail the node instead. Either way, never
trust a green run on its own.
This isn't a reason to avoid expressions (a Code node has silent traps of its own). It's a reason to test with real items:
null where you expected data is the symptom.
validate_workflow and a green execution won't tell you.{{ (() => { try { return JSON.stringify(<expr>) } catch (e) { return 'ERROR: ' + e.message } })() }}$jmespath given a non-object argument).These appear in the editor preview; at runtime most of them resolve to null instead (see above).
"Cannot read property 'X' of undefined" → Parent object doesn't exist → Check your data path
"X is not a function" → Trying to call method on non-function → Check variable type
Expression shows as literal text → Missing {{ }} → Add curly braces
String:
.toLowerCase(), .toUpperCase().trim(), .replace(), .substring().split(), .includes()Array:
.length, .map(), .filter().find(), .join(), .slice()DateTime (Luxon):
.toFormat(), .toISO(), .toLocal().plus(), .minus(), .set()Number:
.toFixed(), .toString()+, -, *, /, %JSON query:
$jmespath(object, "query"): filter/pick/aggregate nested JSON (see the $jmespath() section for the quoting rules).bodyEssential Rules:
.body{{ }} become null silently. Check output values after a test run$jmespath(object, "query"): 'string', `number`, "field"; json. prefix over .all()Most Common Mistakes:
{{$json.name}} in webhooks → Use {{$json.body.name}}{{$json.email}} in Code → Use $json.email{{$node.HTTP Request}} → Use {{$node["HTTP Request"]}}For more details, see:
Need Help? Reference the n8n expression documentation or use n8n-mcp validation tools to check your expressions.
czlonkowski/n8n-skills
e037563
Also appears in
last in sync Jun 19, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.