I was building a lead-notification workflow for a client. The setup was straightforward: Typeform submission triggers n8n, n8n sends a Slack message to the sales channel. The problem was the message body. The client wanted the lead's name, the form they filled out, the submission timestamp in Paris time, and a calculated score based on three answers. Four fields, each needing a different transform.

I could have added four separate Set nodes to reshape each field. Instead, I wrote four expressions directly inside the Slack node's Message field. The workflow stayed at two nodes, and I had exactly what I needed in under ten minutes.

That's what expressions are for. They let you transform data inline, right where you need it, without adding nodes just to reshape fields. This post covers the syntax from scratch, the variables I use most often, date and time formatting with Luxon, and when to stop using expressions and reach for the Code node instead.

What an expression is

An expression in n8n is a JavaScript snippet wrapped in double curly braces: {{ your_code_here }}. You write it directly inside any text field in any node by clicking the equals (=) icon to the right of the field label. That switches the field from static text to expression mode.

Everything inside the braces runs as JavaScript. The result of the expression replaces the braces when the workflow executes. If you write {{ 2 + 2 }}, the field gets the value 4. If you write {{ $json.email.toLowerCase() }}, the field gets the incoming email address converted to lowercase.

Expression fields evaluate once per item. If your workflow is processing 50 rows from a spreadsheet, each expression evaluates 50 times, once with each row's data as the current context. This matters when you're using $json: it always refers to the current item, not the entire batch.

Expressions support full JavaScript: ternary operators, string methods, array methods, arithmetic, template literals, and conditional logic. The constraint is that you can't declare functions, use await, or write multi-statement blocks. For that you need the Code node, which I cover later.

The core variables

n8n gives you a set of variables you can reference inside any expression. These are available in every node without any setup. Here are the ones I use in almost every workflow.

$json

$json is the data object of the current item. If your workflow just received this from a webhook:

{ "name": "Priya Sharma", "email": "priya@example.com", "score": 87, "tags": ["enterprise", "india"] }

Then {{ $json.name }} returns Priya Sharma, {{ $json.score }} returns 87, and {{ $json.tags[0] }} returns enterprise.

For nested objects, just keep dotting in: {{ $json.address.city }}. For arrays, use bracket notation: {{ $json.tags[1] }}. If a field might not exist, I use optional chaining: {{ $json.address?.city ?? "Unknown" }}. The ?? operator returns the right side if the left is null or undefined.

$input

$input gives you access to all items coming into the current node, not just the one being processed right now. The methods I use:

I reach for $input when I need to count items or pull a value from a different item in the same batch. For example, to show how many leads came in with one notification, I'd write {{ $input.all().length }} new leads this hour in the message field.

Referencing a named node: $('NodeName')

This one is less documented but more powerful. You can reference data from any upstream node by name using $('NodeName'). The methods are the same as $input: .all(), .first(), .last().

Say your workflow has a node named "Get Contact" that fetched a contact record, and later you're in a "Send Email" node wanting to use that contact's name. You don't need to pass it through every intermediate node. Just write {{ $('Get Contact').first().json.name }}.

This is how I handle split branches that rejoin. One branch fetches CRM data, another fetches calendar availability. In the final Slack node I reference both by name, and n8n picks the right item from each.

$now and $today

$now is the current date and time as a Luxon DateTime object. $today is today's date at midnight. Both are ready to call Luxon methods on directly.

{{ $now.toISO() }} // "2026-09-21T14:30:00.000+05:30" {{ $now.toFormat('yyyy-MM-dd') }} // "2026-09-21" {{ $now.toFormat('dd MMM yyyy') }} // "21 Sep 2026" {{ $now.setZone('Europe/Paris').toFormat('HH:mm') }} // current time in Paris

I build these timestamps into every notification. "Lead received at {{ $now.setZone('Asia/Kolkata').toFormat('HH:mm, dd MMM') }}" is more useful than an ISO string, and it's one expression.

$runIndex

$runIndex is a zero-based counter that tells you which item you're on in the current execution. First item is 0, second is 1, and so on. I use it when generating sequential IDs or adding a row number to a spreadsheet export: {{ $runIndex + 1 }}.

$vars

$vars gives you access to workflow variables you set in the workflow's Variables panel (the toolbar at the top). These are static values shared across all executions of that workflow. I keep API base URLs, environment labels, and default values there so I can change them once without hunting through every node.

{{ $vars.API_BASE_URL }}/contacts {{ $vars.ENV === "production" ? "live" : "test" }}

Date and time formatting with Luxon

n8n uses Luxon for date handling inside expressions. It's available everywhere without any import. The key thing to understand is that $now is already a Luxon DateTime instance, so you call methods on it directly. For incoming date strings, you need to parse them first.

Parsing an incoming date string

If $json.created_at is the string "2026-09-21T09:00:00Z", you can parse it and reformat it like this:

{{ DateTime.fromISO($json.created_at).toFormat('dd MMM yyyy, HH:mm') }}

The DateTime global is Luxon's main class, available directly in n8n expressions. fromISO parses ISO 8601 strings. fromMillis parses Unix timestamps in milliseconds. fromFormat parses custom formats using Luxon's token table.

Date arithmetic

Luxon's .plus() and .minus() take duration objects. I use this for follow-up scheduling, expiry dates, and reporting windows:

// 7 days from now, ISO format {{ $now.plus({ days: 7 }).toISO() }} // Start of this month {{ $now.startOf('month').toFormat('yyyy-MM-dd') }} // How many days since a date field {{ $now.diff(DateTime.fromISO($json.start_date), 'days').days }}

Timezone handling

Every DateTime object has a timezone. $now is in UTC. To display in another zone, use .setZone():

{{ $now.setZone('Asia/Kolkata').toFormat('HH:mm, dd MMM') }} {{ $now.setZone('Europe/Paris').toFormat('HH:mm z') }}

This is the one I reach for when I'm building notifications for a client in France but the server runs on UTC. I display timestamps in their local time without storing a separate field.

String operations in expressions

JavaScript string methods work directly in expressions. Here are the patterns I use most:

// Concatenation {{ $json.firstName + " " + $json.lastName }} // Template literal (cleaner for multiple fields) {{ `${$json.firstName} ${$json.lastName} | ${$json.company}` }} // Case transforms {{ $json.email.toLowerCase() }} {{ $json.name.toUpperCase() }} {{ $json.slug.replace(/-/g, " ") }} // Truncate to 100 characters {{ $json.body.length > 100 ? $json.body.slice(0, 100) + "..." : $json.body }} // Split on delimiter and take the first part {{ $json.email.split("@")[0] }} // username from email // Check if a string contains a substring {{ $json.tags.includes("enterprise") ? "Priority" : "Standard" }}

Template literals (backtick strings) are my preference over string concatenation when combining more than two values. They're easier to read and I make fewer mistakes with spacing.

Conditional logic

Ternary operators are the only conditional you can write in a single expression. The pattern is {{ condition ? value_if_true : value_if_false }}. I nest them when I need more than two branches, though past two levels I'll usually move to the Code node:

// Single condition {{ $json.score >= 80 ? "High" : "Low" }} // Two conditions, three outcomes {{ $json.score >= 80 ? "High" : $json.score >= 50 ? "Medium" : "Low" }} // Null/undefined fallback {{ $json.phone ?? "No phone provided" }} // Falsy check (catches empty string, 0, null, undefined) {{ $json.company || "Independent" }}

The difference between ?? and || matters here. ?? only falls back on null or undefined. || falls back on any falsy value, including 0 and empty string. If your field can legitimately be zero, use ??.

Array operations in expressions

When a field holds an array, you can manipulate it inline:

// Join an array into a string {{ $json.tags.join(", ") }} // Count items {{ $json.items.length }} // Get the last element {{ $json.items[$json.items.length - 1] }} // Filter (returns array) {{ $json.items.filter(i => i.status === "active").length }} active items // Map and join (flattens to string in one step) {{ $json.contacts.map(c => c.name).join(", ") }}

The catch with array methods in expressions: the return value must be a primitive (string, number, boolean) for most field types. If you need the filtered array itself as the output, that's a Code node job.

When to use the Code node instead

Expressions cover 80% of what I need. The other 20% is where the Code node comes in. It gives you a full JavaScript function body: multiple statements, loops, try/catch, and the ability to return a different number of items than you received.

Situation Expression field Code node
Format a date or string inline Yes Overkill
Conditional value for one field Yes (ternary) Overkill
Multiple transforms on the same item One expression per field Better: transform once, set all fields
Loop over all items and aggregate No Yes
Return a different number of output items No Yes
Multi-step logic (build a URL, validate it, then format the result) Possible but messy Yes
Error handling with fallback Limited (?? and ||) Yes (try/catch)
Parse non-standard data formats (CSV inside JSON, etc.) No Yes

What Code node JavaScript looks like

The Code node gives you a $input variable (the full input), and you return an array of items. Each item must have a json property:

// Transform all items: add fullName and a score tier const items = $input.all(); return items.map(item => { const score = item.json.score ?? 0; const tier = score >= 80 ? "hot" : score >= 50 ? "warm" : "cold"; return { json: { ...item.json, fullName: `${item.json.firstName} ${item.json.lastName}`, tier, processedAt: new Date().toISOString() } }; });

Notice the spread operator (...item.json): this copies all original fields so I don't lose them. I only add the new ones. If I wanted to remove a field, I'd destructure it out: const { sensitiveField, ...rest } = item.json; then use rest in the return.

Splitting one item into many

This is the Code node feature I use most. Say an HTTP Request returns a list of orders as a single item with a orders array field. I want to process each order as its own item downstream. The Code node handles this:

const orders = $input.first().json.orders; return orders.map(order => ({ json: { id: order.id, amount: order.amount, status: order.status, customer_email: order.customer.email } }));

In goes 1 item. Out comes N items, one per order. Every subsequent node in the workflow sees N items. I described this pattern in more detail in the event-driven automation guide, but splitting arrays is where I reach for it first.

Three expression patterns I use in every workflow

Pattern 1: Timestamped subject lines

For any email or Slack notification from a workflow, I include the date and time in the subject so it's scannable in an inbox:

// Email subject {{ `[${$now.setZone('Asia/Kolkata').toFormat('dd MMM HH:mm')}] New lead: ${$json.name}` }}

Pattern 2: Safe field access with fallbacks

Incoming data is inconsistent. Fields that should always be present sometimes aren't. I build defensive expressions for anything going into a database or a CRM write:

{{ ($json.firstName ?? "").trim() + " " + ($json.lastName ?? "").trim() }} {{ $json.phone?.replace(/\s+/g, "").replace(/^0/, "+91") ?? "" }} {{ $json.company?.slice(0, 100) ?? "Unknown" }}

Optional chaining (?.) prevents the expression from throwing when a field is missing. The ?? "" at the end returns an empty string instead of undefined, which most downstream nodes handle more gracefully.

Pattern 3: Computed routing values

In error-handling workflows, I use expressions to set a severity or priority field that the IF node then routes on, rather than duplicating the condition logic in the IF node itself:

// In a Set node before the IF node severity: {{ $json.http_status >= 500 ? "critical" : $json.http_status >= 400 ? "warning" : "info" }}

The IF node then just checks severity === "critical". If I need to change the thresholds, I change one expression, not every branch condition.

Expressions inside HTTP Request node headers and body

Expressions work in any field that accepts them, including HTTP Request node headers and JSON body. This is where I see builders get tripped up most: they try to use static JSON in the Body field and wonder why they can't inject values from earlier nodes.

The fix is to switch the Body Content Type to "JSON" and set it to "Expression" mode. Then you can write the entire JSON body as an expression:

{{ JSON.stringify({ contact: { email: $json.email, name: $json.firstName + " " + $json.lastName, source: "n8n-workflow", created_at: $now.toISO() } }) }}

For headers, I use expressions to inject Bearer tokens stored in workflow variables: {{ "Bearer " + $vars.API_TOKEN }}. The token lives in one place and updates everywhere.

What I got wrong at first and how I corrected it

When I started, I reached for the Code node reflexively whenever something felt complex. That meant workflows with Code nodes doing three-line transforms that should have been expressions. The workflows were harder to read and debug because the logic was buried in a code editor instead of visible in the field.

I also underused $('NodeName'). I was passing every field forward through intermediate Set nodes to "make it available later" when I could have just referenced the earlier node by name. Now I only pass fields forward if they'll be modified. Otherwise I reference the source node directly.

The rule I settled on: if a transform fits in one line and produces one value, it's an expression. If I'm writing more than two nested ternaries, or if I need intermediate variables, it's a Code node. The cognitive overhead of reading a three-level nested ternary in a compressed field isn't worth the node savings.

Testing expressions before you run the workflow

n8n's expression editor has a preview pane that shows you what the expression evaluates to with the last test data from the upstream node. I always run my workflow once manually with test data, then edit expressions while the output is visible. The preview updates in real time.

For the Code node, I paste a sample of the input data into a temporary Set node at the start of the workflow so the Code node always has data to test against, even when I'm building in isolation. Once the Code node is stable, I remove the test Set node.

If you're building production workflows and want a more systematic approach to testing, I covered that in the context of the data validation workflow guide, which walks through pre-flight checks you can build using expressions and the IF node before data reaches any write operation.

Summary

Expressions are the fastest way to transform data inline in n8n. The syntax is {{ JavaScript expression }}, accessible from any field with the = icon. The variables I use most: $json for the current item's data, $input.all() when I need the full batch, $('NodeName') to reference upstream nodes by name, and $now for timestamps via Luxon.

The Code node is for the rest: multi-step logic, loops, splitting arrays into separate items, and anything requiring try/catch. It runs Node.js JavaScript in a function body and returns an array of items.

If you want to see how these fit into a full n8n automation build, I run discovery calls to map which transforms in your existing manual processes can be handled with expressions versus which need a Code node or a purpose-built integration node. The ROI difference between "three extra nodes" and "two expressions" in a workflow you run 500 times a month adds up.

Book a Free Discovery Call

Want to estimate the time savings before you commit? The automation ROI calculator takes your current manual time per task and weekly volume and shows you the hours back per month.

Frequently Asked Questions

What is the expression syntax in n8n?

n8n expressions use double curly braces: {{ your_expression_here }}. Inside the braces you write JavaScript and reference built-in variables like $json (current item's data), $now (current datetime via Luxon), and $input (the full input collection). Enable expression mode by clicking the equals (=) icon next to any field in any node.

What is the difference between $json and $input in n8n?

$json refers to the JSON data of the item currently being processed. $input gives access to all items coming into the node, with methods like $input.all() (array of all items), $input.first(), and $input.last(). Use $json when processing one item at a time; use $input when you need to reference or count across multiple items.

When should I use a Code node instead of expressions?

Use expressions for single-field transforms: formatting a date, concatenating strings, pulling a nested value. Use the Code node when you need to loop over all items and reshape the output, call multiple functions in sequence, handle try/catch error logic, or produce a different number of output items than you received as input.

How do I format a date in an n8n expression?

n8n uses Luxon for dates. $now is already a Luxon DateTime object: call $now.toFormat('yyyy-MM-dd') for ISO date or $now.toFormat('dd MMM yyyy') for a human-readable date. To parse an incoming date string, use DateTime.fromISO($json.created_at).toFormat('dd MMM yyyy').

Can I reference data from a previous node in an n8n expression?

Yes. Use $('NodeName').first().json.fieldName to reference a field from any upstream node by its name. This is how you merge data from parallel branches without threading every field through intermediate nodes. The node name must match exactly, including capitalisation.

Suyash Raj
Suyash Raj Founder, Voxdonna AI and AiSewak. Writes here about n8n, AI agents, and voice automation.