SkillAgentSearch skills...

n8n-expression-syntax

Validate n8n expression syntax and fix common errors

Install / Use

npx skills add czlonkowski/n8n-skills --skill n8n-expression-syntax

Installs into whichever agent you are using.

About this skill
📄

SKILL.md

Installable skill definition

Quality Score

89/100

Category

Automation

Supported Platforms

Universal

Tags

Our assessment of n8n-expression-syntax

n8n-expression-syntax scores 89/100 on our quality scale, 745th of 1,657 Automation skills we index (top 45%).

Its SKILL.md is 20 KB long, well organised into 55 sections with 33 code examples: a thorough specification that gives an agent plenty to work with.

With 6,309 GitHub stars, it is one of the more widely adopted skills in the catalogue.

Substance
30/30
Structure
20/20
Description
8/15
Adoption
16/20
Freshness
15/15

Maintenance, license and trust

  • The repository was last updated 10 days ago, so n8n-expression-syntax is actively maintained.
  • It is released under the MIT license, a permissive license that allows use, modification and commercial use with attribution.
  • Its trust signals score 100/100, with no cautions. These come from repository metadata, not a code audit — read the skill file before letting an agent act on it.

n8n-expression-syntax compared with similar skills

All 4 of these similar skills score higher than n8n-expression-syntax; compare them before choosing.

SkillScoreStarsUpdatedFormat
n8n-expression-syntax (this skill)by czlonkowski896.3k10d agoSKILL.md
Agent-Reachby Panniantong10085.6k11d agoCLAUDE.md
rufloby ruvnet10073.3ktodayCLAUDE.md
Scraplingby D4Vinci10083.9ktodayMCP Server
algorithmic-artby anthropics100177.9k4d agoSKILL.md

Frequently asked questions

How do I install n8n-expression-syntax?
Run npx skills add czlonkowski/n8n-skills --skill n8n-expression-syntax. The install tabs above show the steps for each supported agent.
Which AI agents does n8n-expression-syntax work with?
It is written for Universal, as a SKILL.md file. Other agents that read the same format can often use it too.
Is n8n-expression-syntax safe to use?
It is MIT-licensed and scores 100/100 on trust signals. Skills are instructions an agent will follow, so read the file before installing it and do not approve commands you do not understand.
Is n8n-expression-syntax still maintained?
The repository was last updated 10 days ago, so n8n-expression-syntax is actively maintained.

name: n8n-expression-syntax description: 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.

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:

{{$json.fieldName}}
{{$json['field with spaces']}}
{{$json.nested.property}}
{{$json.items[0].name}}

$node - Reference Other Nodes

Access data from any previous node:

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

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

$env - Environment Variables

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:

  • 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

{
  "headers": {...},
  "params": {...},
  "query": {...},
  "body": {           // ⚠️ USER DATA IS HERE!
    "name": "John",
    "email": "john@example.com",
    "message": "Hello"
  }
}

Correct Webhook Data Access

❌ 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

// 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

// 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

// 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!

// ❌ 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

// ❌ WRONG
path: "{{$json.user_id}}/webhook"

// ✅ CORRECT
path: "user-webhook"  // Static paths only

❌ Credential Fields

// ❌ WRONG
apiKey: "={{$env.API_KEY}}"

// ✅ CORRECT
Use n8n credential system, not expressions

The transform gatekeeper

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

  1. Expression ({{ ... }}) in the consuming field. Property access, method chains (.map().filter().join()), ternaries, string building, Luxon date math — if it's "take A, produce B" without intermediate variables, it's an expression. This covers most "just transform this" cases.

    • Querying nested JSON (filter an array, pick fields, sum, sort, flatten) → $jmespath() inside that same expression, before you split into items or chain .map().filter(). One query replaces a Split Out → Filter → Aggregate chain. Rules below.
  2. Arrow-function IIFE inside an Edit Fields field. When the logic needs intermediate variables, branching, or comments but still operates on one item, wrap it in an immediately-invoked arrow function right in the field value:

    ={{ (() => {
        const items = $json.line_items;
        const subtotal = items.reduce((sum, it) => sum + it.price * it.qty, 0);
        const tax = subtotal * 0.08;
        return (subtotal + tax).toFixed(2);
    })() }}
    

    The outer (...) brackets the function; the trailing () invokes it. Drop either and n8n refuses to run. Inside you get the full expression scope ($json, $('Node'), $now, Luxon) plus const/let, if/switch, try/catch, and regex. No require, no await.

  3. Code node — last resort. Only when you need multi-item aggregation across the whole dataset ($input.all()), an allowlisted library, or async work.

Why the order matters. It's not style — it's readability and performance. The Code node runs in a sandboxed VM with per-invocation setup and value marshaling — a cold-start cost that can reach 500–1000ms before your logic runs. (It amortizes on warm, high-item-count runs, so treat this as the common-case cost, not a universal constant.) The same logic in an expression or Edit Fields IIFE runs in-process in single-digit milliseconds and skips the sandbox entirely. For pure single-item shaping that's a large gap with no functional difference, and it compounds on hot paths like per-request webhooks. The expression also stays visible in the field that uses it, instead of hiding in an upstream node someone has to open to understand. Reach past a stage only when the input or scope genuinely demands it.

$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 → [] |

  • 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.

The Set-node antipattern and branch convergence

Delete Set nodes that feed one consumer

A Set / Edit Fields node whose only job is to extract a value and hand it to one downstream node is dead weight. Inline its expression 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: count how many downstream nodes reference each field the Set produces.

  • 0 or 1 → delete, inline at the consumer.
  • 2+ → it may earn its place.

Legitimate exceptions — keep the Set when:

  • 2+ consumers read the same derived value and the derivation is non-trivial (a name aids readability and you compute it once).
  • It's a sub-workflow's final Return node, shaping the output contract. Here the "single consumer" is every caller, so the Set is the API boundary — and with Include Other Fields: false it whitelists the output shape so internal scratch fields don't leak.
  • You're renaming or whitelisting fields and want that visible in one place rather than spread across consumer expressions.

Branch convergence: anchor with a NoOp

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.


Validation Rules

1. Always Use {{}}

Expressions must be wrapped in double curly braces.

❌ $json.field
✅ {{$json.field}}

2. Use Quotes for Spaces and Special Characters

Field or node names with spaces, diacritics, or special characters req

Truncated for display — read the full file on GitHub.

Related Skills

View on GitHub
GitHub Stars6.3k
CategoryAutomation
Updated10d ago
Forks1.0k

Languages

Shell

Trust signals

100/100

From repository metadata: license, adoption, age and documentation. Not a code audit — see the Safety scan above for what the skill file itself contains.

No cautions