czlonkowski/n8n-skills/n8n-expression-syntax

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

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.

Install

npx vskill@latest install czlonkowski/n8n-skills/n8n-expression-syntax

SKILL.md

n8n Expression Syntax

Expert guide for writing correct n8n expressions in workflows.


Expression Format

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)

Core Variables

$json - Current Node Output

Access data from the current node:

javascript
{{$json.fieldName}}
{{$json['field with spaces']}}
{{$json.nested.property}}
{{$json.items[0].name}}
$node - Reference Other Nodes

Access data from any previous node:

javascript
{{$node["Node Name"].json.fieldName}}
{{$node["HTTP Request"].json.data}}
{{$node["Webhook"].json.body.email}}

Important:

  • Node names must be in quotes
  • Node names are case-sensitive
  • Must match exact node name from workflow
$now - Current Timestamp

Access current date/time:

javascript
{{$now}}
{{$now.toFormat('yyyy-MM-dd')}}
{{$now.toFormat('HH:mm:ss')}}
{{$now.plus({days: 7})}}
$env - Environment Variables

Access environment variables:

javascript
{{$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:

  • Store values in credentials instead
  • Use a Set node with manually entered values
  • Pass values through webhook query parameters

🚨 CRITICAL: Webhook Data Structure

Most Common Mistake: Webhook data is NOT at the root!

Webhook Node Output Structure
javascript
{
"headers": {...},
"params": {...},
"query": {...},
"body": { // ⚠️ USER DATA IS HERE!
"name": "John",
"email": "john@example.com",
"message": "Hello"
}
}
Correct Webhook Data Access
javascript
❌ 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.


Common Patterns

Access Nested Fields
javascript
// 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']}}
Reference Other Nodes
javascript
// 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}}
Combine Variables
javascript
// 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}}"
}

When NOT to Use Expressions

❌ Code Nodes

Code nodes use direct JavaScript access, NOT expressions!

javascript
// ❌ 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();
❌ Webhook Paths
javascript
// ❌ WRONG
path: "{{$json.user_id}}/webhook"
// ✅ CORRECT
path: "user-webhook" // Static paths only
❌ Credential Fields
javascript
// ❌ WRONG
apiKey: "={{$env.API_KEY}}"
// ✅ CORRECT
Use n8n credential system, not expressions

The transform gatekeeper

Before you add a node — or write any code — to transform data, walk this order and stop at the first that fits:

  1. The service's own node does it in its own fields: a CRM node's field slots, the Slack node's Blocks field, a

Data Table insert's per-column slots. HTTP Request only when no node covers the operation.

  1. A native node for the job. Filter, IF / Switch, Summarize, Aggregate, Split Out, Merge, Compare Datasets, Remove

Duplicates, Sort + Limit, Date & Time, XML / HTML / Extract From File, HTTP Request pagination, Crypto. The job-to-node table in using-n8n-mcp-skills maps jobs to nodes; their configs are in n8n-node-configuration → NATIVE_NODES.md.

  1. A short expression ({{ ... }}) 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.

  • Querying nested JSON for one field (pick, filter, sum inside an array) → $jmespath() in that same expression

(rules below). When downstream needs one item per element, use Split Out / Filter / Aggregate instead.

  1. Code node, only for the closed list: loops or recursion no node expresses, state across runs, parsing no node

handles, an allowlisted library, heavy all-items work (thousands of items, stated or measured), or one short Code node (~15 lines) replacing 4+ glue nodes. Give it a 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:

WriteNotWhat 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 > 100000parse 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-nameparse 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 → []
  • Results: missing path or index → null; filter with no match → []; sum() over an empty

projection → 0.

  • First argument must be an object or array. A string (e.g. an HTTP Request with a text

response) or undefined ($json.missingField) throws the same expected two arguments error. That one does fail the node.

  • Handy pieces: 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}.

  • It returns a value, not items. Use it where the result feeds one field (message text, HTTP

body, IF/Filter condition). When downstream needs one item per match, narrow with $jmespath in Edit Fields and follow with a single Split Out on that field.

  • Where it exists: expressions and JavaScript Code nodes (same argument order). Not in native

Python Code nodes.

Built-in helper functions

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.

NeedExpressionNote
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() }}.
  • A helper called on null or undefined throws: guard with ?. ({{ $json.email?.extractDomain() }}) or $ifEmpty().
  • A helper that throws (bad unit, wrong argument count, a missing value) fails the node; a plain JavaScript error inside

{{ }} returns null silently. Test with real items either way.

The Set-node antipattern and branch convergence

Delete Set nodes that only re-map fields for the next node

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)?

  • Yes → delete it, inline the expressions at the consumer.
  • No, it creates something → keep it as a named step (name it for what it produces).

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 95 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationPassed, 80 of 100Effective. 1 run. Evaluated 7 months ago.

Source

Popularity

Repository stars
3,817
Repository forks
663
7-day trend score
41

Stars and forks are for the whole GitHub repository, not this skill alone. Refreshed 22 Mar 2026, 7 months ago.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/czlonkowski/n8n-skills/n8n-expression-syntax/badge)](https://verified-skill.com/skills/czlonkowski/n8n-skills/n8n-expression-syntax)

Something wrong with this skill or its page? Tell us.