teachyou.ai academy
← All posts
n8n

n8n Expressions: Writing Dynamic Data Transformations

Pramod Dutta · Jun 8, 2026 · 14 min read

Why n8n expressions are the real skill gap

Anyone can drag nodes onto an n8n canvas and connect them into a workflow. The gap between a workflow that looks finished and one that actually survives contact with real data is almost always expressions. If you've ever stared at a node that failed with "Cannot read properties of undefined" or watched a workflow silently pass along the wrong field, the culprit is usually a badly written expression, or no expression at all where one was needed.

n8n expressions are small JavaScript snippets wrapped in {{ }} that let you pull data from previous nodes, transform it on the fly, and build values dynamically instead of hardcoding them. They're what turns a static demo workflow into something that can handle a webhook payload nobody has seen before, a CSV with inconsistent casing, or an API response that sometimes returns null. If you're building anything beyond a linear "trigger to Slack message" automation, you will be writing expressions constantly, and writing them well is the difference between a workflow that runs for a year without complaints and one that pages you every Tuesday.

This guide walks through the expression syntax n8n actually uses, the built-in variables and methods you'll reach for most, and the patterns that separate a fragile workflow from a resilient one.

The anatomy of an n8n expression

Every expression in n8n lives inside double curly braces, and anything outside those braces is treated as plain text. That means you can mix static text and dynamic values in the same field:

Hello {{ $json.firstName }}, your order #{{ $json.orderId }} has shipped.

n8n evaluates whatever is between {{ and }} as JavaScript. The most common thing you'll reference is $json, which points to the JSON data of the current item as it arrives at that node. If the incoming item looks like this:

{
  "customer": {
    "name": "Asha Verma",
    "email": "asha@example.com"
  },
  "orderTotal": 4599,
  "items": ["Notebook", "Pen", "Sticky Notes"]
}

then {{ $json.customer.name }} returns Asha Verma, and {{ $json.items.length }} returns 3. You can chain any valid JavaScript off of that reference: {{ $json.customer.email.toLowerCase() }}, {{ $json.orderTotal / 100 }}, or {{ $json.items.join(", ") }}.

The key mental model: expressions run per item. If your node receives five items, the expression re-evaluates five times, once with $json pointing at each item in turn. This is why expressions are so powerful for transformation-heavy workflows — you write the logic once and it applies across an entire batch.

Referencing data from other nodes

$json only gives you the current node's input. Most real workflows need data from further back in the chain — the original webhook payload, a lookup you did three nodes ago, or a value from a node that isn't directly upstream. That's what $node and $("Node Name") are for.

{{ $("Webhook").item.json.body.customerId }}

This pulls the customerId field from the body of whatever the node named "Webhook" received, regardless of how many transformation steps happened afterward. It's one of the most-used patterns in n8n because workflows constantly need to "remember" something from earlier in the flow after the data shape has changed.

A common real-world case: you fetch a customer record, then call a payment API, then need to combine fields from both in a final Slack message.

{{ $("Get Customer").item.json.name }} just paid {{ $json.amount / 100 }} {{ $json.currency.toUpperCase() }}

Here $json refers to the current node's input (the payment API response), while $("Get Customer") reaches back to grab the name from an earlier step. Without this, you'd need to manually pass every field forward through every node — which gets unmanageable fast in anything beyond a 3-node workflow.

You can also reference $input.all() to get every item currently in the node's input array, which is useful when you need to look at the whole batch rather than one item at a time — for example, counting how many items passed a filter, or checking if any item matches a condition.

There's an important distinction between $("Node Name").item and $("Node Name").first() that trips people up early on. .item gives you the item from that node that corresponds to the *current* item being processed, following n8n's internal item-linking. .first() always gives you the first item that node produced, regardless of which item you're currently on. If a node upstream only ever emits a single item — like a "Get Customer" lookup that returns one record — the two are interchangeable. But once a node emits multiple items and you're inside a loop further downstream, .item is almost always what you want, because it keeps the data correctly paired with the item currently flowing through the workflow. Reaching for .first() in that situation is a common cause of "why did every row in my output get the same customer name" bugs.

Built-in variables you'll use constantly

Beyond $json, n8n exposes a set of variables that cover the situations you hit in almost every workflow:

  • $now — the current timestamp as a Luxon DateTime object, useful for timestamps and date math
  • $today — the current date at midnight, handy for date comparisons without time-of-day noise
  • $workflow — metadata about the running workflow, like $workflow.id and $workflow.name
  • $execution — details about the current execution, including $execution.id
  • $itemIndex — the position of the current item in the input array, starting at 0
  • $env — environment variables configured on your n8n instance
  • $vars — custom variables you've defined at the instance level

A practical use of $now is generating a timestamp for a log entry or a filename:

{{ $now.toFormat("yyyy-MM-dd_HH-mm-ss") }}

That expression, dropped into a "Set" node building a filename, produces something like 2026-07-03_14-22-05, which you can then append .csv or .json to. Since $now is a Luxon object, you get the entire Luxon API for free — .plus({ days: 7 }), .diff(), .toISO(), and so on, instead of hand-rolling date math in raw JavaScript.

$itemIndex is worth calling out because it solves a problem people often reach for a Code node to fix: numbering items in a batch.

Item {{ $itemIndex + 1 }} of {{ $input.all().length }}

$workflow and $execution are less about transforming data and more about self-awareness — a workflow that logs its own name and execution ID into an error-tracking sheet, for example, so you can trace a failure back to the exact run that caused it:

Workflow "{{ $workflow.name }}" (execution {{ $execution.id }}) failed at {{ $now.toISO() }}

That single line, dropped into an error-handling branch that writes to a logging spreadsheet or sends a Slack alert, turns a vague "something broke" into a message your future self can act on immediately without digging through the executions list.

Writing conditional logic inside expressions

Expressions aren't limited to simple field lookups — you can write ternaries, use logical operators, and call array/string methods, all inline. This is where expressions start replacing entire IF nodes for small decisions.

{{ $json.orderTotal > 5000 ? "Priority" : "Standard" }}

You can stack conditions with logical operators:

{{ $json.status === "paid" && $json.items.length > 0 ? "Ready to ship" : "On hold" }}

And you can fall back gracefully when a field might be missing, using the nullish coalescing operator or optional chaining — both are fully supported since expressions run through a modern JavaScript engine:

{{ $json.customer?.phone ?? "No phone on file" }}

That single line does two jobs: ?. prevents a crash if customer itself is undefined, and ?? supplies a default if phone specifically is missing or null. This combination is one of the highest-leverage patterns in n8n because so many workflow failures come from assuming a field will always exist. APIs change, forms have optional fields, and CSV exports have blank cells — defensive expressions catch all of that before it becomes a broken execution.

Real transformation example: cleaning and reshaping incoming data

Let's say you're building a workflow that receives leads from a web form via webhook, and the payload is messy — inconsistent casing, phone numbers with stray characters, and a full name field you need to split into first and last name for your CRM. Here's what that looks like in a Set node using expressions for each field:

{
  "firstName": "{{ $json.body.fullName.trim().split(' ')[0] }}",
  "lastName": "{{ $json.body.fullName.trim().split(' ').slice(1).join(' ') }}",
  "email": "{{ $json.body.email.toLowerCase().trim() }}",
  "phone": "{{ $json.body.phone.replace(/[^0-9]/g, '') }}",
  "source": "{{ $json.body.utm_source ?? 'direct' }}",
  "submittedAt": "{{ $now.toISO() }}"
}

Walk through what each line does:

  • firstName splits the trimmed full name on spaces and takes the first token
  • lastName takes everything after the first token and rejoins it, so "Maria De Souza" correctly becomes lastName "De Souza" instead of just "De"
  • email normalizes casing and whitespace so duplicate-detection logic downstream isn't fooled by "Asha@Example.com " vs "asha@example.com"
  • phone strips everything that isn't a digit using a regex, so "(555) 123-4567" becomes "5551234567"
  • source falls back to "direct" if no UTM parameter was captured
  • submittedAt stamps the item with a clean ISO timestamp regardless of what timezone the form submission came from

This is the kind of transformation that, done manually with five separate Set/Function nodes, bloats a workflow and makes it hard to read. Done with expressions in a single Set node, it's five lines you can scan in ten seconds.

Working with arrays and loops inside expressions

A frequent stumbling block is data shaped as an array when you need a single string, or vice versa. n8n expressions give you the full range of JavaScript array methods, so you rarely need a Code node just to reshape a list.

Turning an array of line items into a readable summary string:

{{ $json.items.map(item => `${item.name} x${item.qty}`).join(", ") }}

Given items like [{"name":"Mug","qty":2},{"name":"Notebook","qty":1}], this produces:

Mug x2, Notebook x1

Filtering an array before summarizing it — for example, only counting items that are in stock:

{{ $json.items.filter(item => item.inStock).length }}

Summing a numeric field across an array, which comes up constantly when calculating order totals or aggregating metrics:

{{ $json.items.reduce((sum, item) => sum + item.price * item.qty, 0) }}

These three patterns — map, filter, reduce — cover the overwhelming majority of "I need to do something to every item in this list" situations you'll hit inside a single expression, without needing to drop into a full Code node.

String formatting and text cleanup patterns

A large share of real-world expression work isn't math or logic at all — it's cleaning up messy text so it's presentable or matchable downstream. A few patterns come up so often across different workflows that they're worth walking through on their own.

Truncating a long description for a notification, so a Slack or email message doesn't get flooded by a full product description:

{{ $json.description.length > 100 ? $json.description.slice(0, 100) + "..." : $json.description }}

Turning a slug or a database enum value into something human-readable for a message or report:

{{ $json.status.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()) }}

Given a status field like payment_failed, that expression produces Payment Failed — the first replace swaps underscores for spaces, and the second capitalizes the first letter of every word using a regex with a callback function. It's a small piece of JavaScript, but it saves you from hardcoding a lookup table of enum-to-label mappings in a Switch node.

Padding a numeric ID for consistent formatting, useful when generating order numbers or invoice references:

{{ "INV-" + String($json.invoiceNumber).padStart(6, "0") }}

That produces INV-000482 from a raw invoice number of 482, which is the kind of formatting finance and operations teams expect from real invoicing systems rather than a bare integer.

Expressions vs. the Code node: when to reach for which

A common mistake is treating the Code node as the default tool for any transformation, when a well-placed expression is simpler, more visible, and easier for teammates to audit later. As a rule of thumb:

  • Use an expression when you're transforming a value that maps cleanly to one output field — string cleanup, math on a number, a ternary decision, building a formatted message
  • Use the Code node when you need to branch into multiple output items, run a loop with side effects, call an external library, or when the logic genuinely needs more than a few lines to read clearly
  • Avoid Code nodes for simple lookups or fallbacks — $json.field ?? "default" in a Set node is more maintainable than a Code node that does the same thing in five lines of if statements

The practical benefit of favoring expressions is that they show up directly in the node UI, so anyone opening the workflow later can see exactly what transformation is happening without opening a code editor. That matters a lot once workflows are shared across a team or handed off to someone else to maintain.

Debugging expressions that aren't working

When an expression throws an error or silently returns the wrong value, the fix is almost always one of these:

  • Check the data shape first. Click on the node before the one you're editing and look at its actual output in the Table or JSON view. It's extremely common to assume a field is at $json.data.email when it's actually nested one level deeper or named differently.
  • Watch for arrays vs. single objects. If a node like an HTTP Request returns an array of results, $json in the *next* node refers to one item from that array (since n8n processes item-by-item), not the whole array. Trying to call .length on $json in that case will error because you're on a single object, not a list.
  • Use optional chaining defensively. Anywhere a field might legitimately be missing — optional form fields, API responses that vary by account tier, webhook payloads from third parties you don't control — wrap the access in ?. and supply a ?? fallback.
  • Test expressions in isolation. n8n's expression editor shows a live preview of the resolved value as you type, using the current item's real data. Use that preview constantly rather than running the whole workflow to check one field.
  • Remember expressions run per item. If something works for the first item in a test run but breaks on item three, the data shape probably differs between items — for example, one lead submitted a phone number and another left it blank.

Common patterns worth keeping in your back pocket

A short list of expressions that come up so often they're worth memorizing rather than re-deriving each time:

  1. Default a possibly-missing value: {{ $json.notes ?? "" }}
  2. Convert cents to a display price: {{ ($json.amountInCents / 100).toFixed(2) }}
  3. Build today's date as a filename-safe string: {{ $today.toFormat("yyyy-LL-dd") }}
  4. Check if an array has any items before branching logic: {{ $json.results.length > 0 }}
  5. Pull the first item out of an array response safely: {{ $json.results?.[0]?.id ?? null }}
  6. Concatenate first and last name with proper spacing: {{ [$json.firstName, $json.lastName].filter(Boolean).join(" ") }}
  7. Reference the trigger node's raw payload from deep in the workflow: {{ $("Webhook").item.json.body }}

That sixth one is worth a second look — wrapping the join in .filter(Boolean) means if lastName happens to be empty, you don't end up with a trailing space or an awkward "Asha " with nothing after it. Small details like this are exactly what separates expressions that work in your test data from expressions that hold up once real, messy, inconsistent input starts flowing through.

Putting it together in an AI Agent workflow

Expressions become even more important once you start building workflows around n8n's AI Agent node, because you're constantly passing dynamic context into prompts, parsing structured output back out of model responses, and routing based on what the agent decided. A prompt built entirely from expressions might look like:

You are helping {{ $("Get Customer").item.json.name }} with a support request.
Their account tier is {{ $("Get Customer").item.json.tier ?? "free" }}.
Their message: {{ $json.body.message }}
Respond in a tone appropriate for a {{ $("Get Customer").item.json.tier === "enterprise" ? "priority" : "standard" }} customer.

Every line here is doing real work: pulling customer context from an earlier node, defaulting a missing tier gracefully, inserting the live message text, and adjusting instructions conditionally based on account status. This is the exact skill — combining $json, cross-node references, fallbacks, and conditional logic inside a single expression — that turns a hardcoded demo agent into one that actually adapts to each request it receives.

If you want to go deeper into building agentic workflows in n8n, including how expressions feed context into AI Agent nodes, how to structure memory and tool calls, and how to debug agent decisions in production, that's exactly what we cover hands-on in the n8n AI Agent Tutorial course on teachyou.ai. It walks through building real agent workflows from scratch rather than just wiring together demo nodes, with the same attention to defensive, production-ready patterns covered in this guide.