Chapter 14

Expressions: it all starts with {{ $json.field }}

Node fields (Text on Slack, Row Values on Google Sheet, Subject on Gmail) constantly need to "drop in the data the previous node fetched". In n8n you do that with an Expression — a small piece of code wrapped in {{ }} that n8n evaluates into a real value before the node runs. This chapter covers the five forms you will use most, how to build an expression by dragging, dates, times and string handling, and how to read a red error, all in one pass.

Why you need expressions

Chapter 9 already told you that what travels between n8n nodes is items, one "row" at a time. But knowing what an item looks like is not enough — when a downstream node actually needs the data, you have to put an upstream field into a field on the downstream node, and the bridge for that is the expression.

Here are a few everyday examples you cannot build without one:

  • Someone submits an order through Google Form → Slack posts "New order #1042, customer Chen Xiaoming, amount $3,500". Those three bold values come from upstream; you cannot hard-code them.
  • Every day at 08:00 → Gmail sends the "2026-08-16 daily sales report", and the date has to be today, not yesterday.
  • An IF node condition: "notify the manager only when the amount is > 1000". The value in front of that 1000 has to be pulled from the current item's amount field for the comparison.
  • Adding a row to Google Sheet, where the name column takes the previous node's name and the Email column takes email.

Without expressions you could only build one workflow per customer — which reduces n8n to a very expensive cron job. Expressions are what make one workflow serve an unlimited number of records, and no other n8n feature saves you more time.

Chapter goal: by the end you will know what an expression is, how to switch a field into Expression mode, the five forms you will use most, how to handle dates and strings, how dragging saves you from typing code, and how to debug a red error. This chapter is tied to Chapter 9 — an item is "where the data is", and an expression is "how you get it into a field".

An expression is a dynamic fill-in-the-blank

Get the idea straight first: every field inside an n8n node (say the Text field on the Slack node) can be filled in two ways.

What to enterWhat you writeWhat happens at run timeExample
Fixed (static)You type directly and n8n sends it through untouched.The same text on every run.Type hello world in the field → Slack always sends hello world.
Expression (dynamic)Wrap it in {{ }} and write a short piece of JavaScript inside.When n8n reaches this node it evaluates what is inside {{ }} first, then writes the value back.Type {{ $json.name }} in the field; at run time, if the current item's name is Elmo → Slack sends Elmo.

How to switch: at the field label above the field (or on the field itself) you will see an Expression / Fixed toggle (newer UI shows a small fx icon plus an Expression tab). Left = Fixed mode, right = Expression mode. Once you switch to Expression mode the field background turns into a gridded code style and a preview block appears underneath — that is the visual marker for Expression mode, and the gridded background means you have entered the world of code.

A field can also mix static text with an expression. Say the field holds New order {{ $json.orderId }}: the static prefix is sent as it is, and only the {{ }} section gets replaced. This is the most intuitive way to build a template string.

Tip: expressions use a subset of JavaScript plus n8n's own variables. Any JS syntax you already know (+, .toUpperCase(), the ternary operator a ? b : c) works. What you should not do is run heavy logic or call an API in there — that is the job of the Code node (Chapter 20).

The five expressions you will use most

Eight cases out of ten come down to these five. Learn them well and everything else is a combination of them.

#How to write itWhat it meansWhen to use it
1 {{ $json.field }} A field on the current item. 90% of cases — pulling the current order number into a Slack message, the customer name into a Gmail subject, the email into a new Sheet row.
2 {{ $('Google Sheets').item.json.field }} Takes data from a specific upstream node, however many stops sit in between. You have been through IF, Set and Merge and still want the original form data.
3 {{ $now }} The current time (a Luxon DateTime object, in the workflow timezone). Equivalent to DateTime.now(). Adding a date to a Gmail subject, writing a timestamp into a new Sheet row, doing arithmetic against another date.
4 {{ $json.items.length }} The length of an array. A Slack notification saying there are {{ $json.orders.length }} orders in total, or an IF node checking whether there are more than 10.
5 {{ $json.name.toUpperCase() }} Calling a method on a string (the whole JavaScript String set is available). Normalizing case, cutting a string short, trimming whitespace, replacing text.

$json, $now and $() here are built-in variables that n8n injects for you. They are not native JavaScript; n8n prepares them and you simply use them. Chapter 20 spells out the full list — for this chapter, get comfortable with these few.

Warning: $('Google Sheets').item.json.field is the newer form (the one promoted since 0.190+); the older form for the same thing is $node['Google Sheets'].json.field. Both still run, because n8n keeps the old form for backward compatibility. Watch the alignment semantics of the two: $('Node').item explicitly goes through pairedItem to align with the current iteration; $node['Node'].json also takes the matching item from the current run, but the form is missing the .item layer — the behavior is equivalent. Seeing both mixed in someone else's workflow is normal.

Build expressions by dragging (recommended — no syntax to memorize)

Honestly, writing an expression almost never means typing code yourself — the n8n expression editor has a very good drag mechanism, so you do not have to remember any field name; one pull with the mouse and it is written. This is the one trick every n8n beginner should learn first.

  1. Run the upstream node once first

    Click Execute step on that upstream node (the small play button underneath it) so it produces output. Without output, the expression editor does not know which fields you can drag and shows an empty schema.

  2. Open the downstream node you want the expression in

    Double-click the node to open its settings panel. Find the field you want to change (say Text on the Slack node).

  3. Switch the field into Expression mode

    Above the field there is a Fixed / Expression tab (or a small fx icon); click the Expression side. The field background turns into a gridded code style, which means the switch worked.

  4. Drag a field in from the INPUT panel on the left

    Once the expression editor opens, an "INPUT" panel slides out on the left listing every field from the upstream node (you can switch between the Table / JSON / Schema views). Drag a field straight into the expression box and n8n writes the correct form for you: drag name and you get {{ $json.name }}.

  5. Check the Result preview underneath

    Below the expression box there is a Result: line showing, live, the value this expression evaluates to. Seeing the value you expected means you are done; red text or an empty result means the expression has a problem.

  6. Type static text straight in when you need it

    You can add static text before or after the expression you dragged in. Say the expression box currently holds {{ $json.name }} and you add Hi in front → it becomes Hi {{ $json.name }}, and the preview updates immediately to Hi Elmo.

Tip: not sure what a field is called? Switch the INPUT panel to the Schema view — it lists field names and types only, with no values, so the whole structure is clear at a glance. That is exactly why Chapter 9 makes a point of the Schema view.

Dates and times: enter Luxon

n8n expressions handle time with Luxon, a JS date library (not the native Date, and not Moment). Luxon's advantages are timezone support and calendar arithmetic, with far tidier syntax than the native Date.

How to write itResultWhen to use it
{{ $now }}Now, as a Luxon DateTime object (with timezone).Timestamps, Gmail subjects, recording "when this record was processed".
{{ $today }}Today's date with the time at 00:00 (decided by the workflow timezone; behaves much like $now.startOf('day')).When you want the date without the time, or to filter for records added today.
{{ $now.plus({ days: 3 }) }}The same moment three days later.Setting a due date, or working out "notify if there is no reply within three days".
{{ $now.minus({ hours: 1 }) }}One hour ago.An API query for "new data in the last hour".
{{ $now.startOf('day') }}Today at 00:00:00.The start time when you want "everything from today".
{{ $now.toFormat('yyyy-MM-dd') }}The string 2026-08-16.Dropping into a Gmail subject, a file name, or a Sheet column.
{{ $now.toFormat('yyyy-MM-dd HH:mm') }}The string 2026-08-16 14:30.A timestamp that has to include hours and minutes.
{{ DateTime.fromISO($json.created_at) }}Turns an ISO string returned by an API into a Luxon object.When you need to do arithmetic on, or format, a date an API gave you.

Every Luxon calendar unit works (days, hours, months, weeks, years) — do not drop the plural s. The toFormat pattern is Luxon-specific too: yyyy is a four-digit year, MM a two-digit month, dd a two-digit day, HH the 24-hour clock, mm minutes.

Warning: a drifting timezone is the most common date trap. By default n8n uses the system timezone (self-hosted takes the container's timezone, Cloud takes your workspace setting). Note: open Settings → Timezone in the workflow and choose the IANA timezone for your location; for example, Europe/London. After that $now and $today use that zone. For cross-timezone data, state the destination explicitly, for example .setZone('Europe/London').

Joining strings: pick one of three styles

Slack messages, Gmail subjects and Sheet columns are all strings — to drop an upstream field into a sentence, n8n gives you three styles. Pick whichever reads best to you; they all achieve the same thing.

How to write itResultWhen to use it
Mixed into the field:
Hi {{ $json.name }}
Evaluates to Hi Elmo. The one to prefer. As long as what sits outside the {{ }} is static text, mixing directly is the shortest.
Plus sign:
{{ 'Hi ' + $json.name }}
Evaluates to Hi Elmo. When you have to build the string inside the {{ }} — for example to wrap the whole thing in toUpperCase().
Template literal (backtick):
{{ `Hi ${$json.name}, order ${$json.orderId}` }}
Evaluates to Hi Elmo, order 1042. Cleanest when one {{ }} has to interpolate several variables with punctuation between them.

Common string methods (all standard JavaScript String methods):

How to write itWhat it doesExample
.toUpperCase()Upper-cases everything.{{ $json.code.toUpperCase() }} → ABC123
.toLowerCase()Lower-cases everything.{{ $json.email.toLowerCase() }}
.trim()Strips leading and trailing whitespace.Use it to clean up the " Elmo " you got from Google Form.
.slice(0, 100)Takes the first 100 characters.Keeps a Slack message preview from blowing up: {{ $json.body.slice(0, 100) }}
.replace('old', 'new')Replaces text.{{ $json.phone.replace(/-/g, '') }} (removes every -)
.split(',')Splits into an array.Turns a,b,c into ['a','b','c'].
Tip: not sure whether a field on the current item is undefined? Use optional chaining: {{ $json.name?.toUpperCase() }}. Add a ? and, when name does not exist, you get undefined back instead of an error — far tidier than an if check.

Common cases you can copy

Here are the common use cases you will meet and the expression for each — open the workflow, switch the field to Expression mode, and copy them as-is.

The AI to Odoo task flow v2 workflow, with expression references inside
Figure 14-1 The "AI × Odoo task flow v2" example: across six nodes, several fields use expressions to reference the upstream AI node's output, so one workflow handles an unlimited number of tasks dynamically.
ScenarioExpressionWhat it means
Slack message template: new order notification {{ `New order #${$json.orderId} | Customer ${$json.customer} | Amount $${$json.amount}` }} Built in one template literal, with the separator characters typed straight in.
New Google Sheet row, name column {{ $('Webhook').item.json.name }} Explicitly reaches back for name on the Webhook node, so it does not break even if a Set node is dropped in between.
Dynamic date in a Gmail subject {{ 'Daily sales report - ' + $now.toFormat('yyyy-MM-dd') }} Fixed text plus Luxon formatting; at run time it becomes Daily sales report - 2026-08-16.
IF node condition: amount greater than 1000 Left value {{ $json.amount }}, operator >, right value 1000 The IF node does not take a raw boolean — the operator has to be picked in its UI, and only the left and right boxes are expression fields. To combine conditions use several conditions plus AND/OR, or put a Set node in front to work the boolean out first and let IF test that.
Dynamic HTTP Request URL https://api.example.com/orders/{{ $json.orderId }} URLs support mixed expressions too, so orderId goes straight into the path.
Airtable upload: turn the tags array into a string {{ $json.tags.join(', ') }} Some Airtable fields only accept strings, so join turns the array into a comma-separated list.
File name with a timestamp {{ 'report_' + $now.toFormat('yyyyMMdd_HHmm') + '.pdf' }} Gives report_20260816_1430.pdf, so nothing is overwritten by a same-named file.
Ternary operator: use the value if there is one, otherwise a default {{ $json.nickname ? $json.nickname : $json.name }} Nickname first, falling back to the real name when there is none. There is a shorter form: {{ $json.nickname || $json.name }}.

How to read a red error

Red text in an expression field, or a red border around it, is nothing to panic about — n8n tells you the reason right below the expression panel, and once you can read that message you can fix it. These four are the most common kinds of red text and their fixes.

  1. The Result shows undefined

    The most common one. Usually a misspelled field name — the real field is name and you typed $json.namee with an extra e. The fix: switch the INPUT panel to the Schema view and confirm how the field is really written (case, spaces, whether it is nested like data.name). Dragging never misspells.

  2. The Result shows [Cannot read property 'X' of undefined]

    The parent of the thing you are reaching for is empty. Take $json.customer.email: the customer field does not exist, so n8n cannot read email and throws this. Two fixes: (a) sidestep it with optional chaining, $json.customer?.email; (b) fix the upstream node first and confirm it really returns customer.

  3. The Result shows [Referenced node doesn't exist]

    You wrote $('Google Sheets') or $node['Google Sheets'], but there is no node called "Google Sheets" on the canvas — usually the node was renamed (double-click a node to rename it), or the case or the spacing does not match. The fix: go back to the canvas and see what the node is actually called (spaces included), then copy that name across.

  4. The Result shows a schema instead of real values

    The preview shows the field structure but no real values? That means the upstream node has not run yet — n8n does not know what it will actually fetch. The fix: click Execute step once on the upstream node so it produces real output, and Result will have real values when you come back.

Concept: the Result: line under the expression panel is your most important debugging tool. While you edit an expression, keep your eyes on Result — check after every character whether it has become the value you want. That way you catch a mistake immediately instead of waiting for the whole workflow to finish to learn that it blew up.

Advanced: how $json, $node and $input differ

Seeing all three mixed in someone else's workflow is confusing, but the logic is simple — they differ on whose data you are taking, and on whether you take one item or all of them.

VariableWhat it refers toWhen to use it
$json The current iteration's item, specifically its json part. 90% of cases. The node runs N times in a row, and each time $json points at a different item.
$binary The binary part of the current item (only there when it has an attachment). When you handle files: $binary.attachment_0.fileName.
$input.item The whole current item object (both json and binary). In a Code node, or when you need json and binary at once.
$input.all() The array of all items this node received (each element carries .json and .binary). Aggregating over the whole batch ($input.all().length, $input.all().map(i => i.json.amount).reduce(...)); available both in expressions and in the Code node (Chapter 20).
$input.first() / $input.last() The first / last item this node received. Taking the head or the tail of a batch for a summary (a report node that only needs the start time, $input.first().json.startedAt).
$('NodeName') The output of any upstream node, not only the one directly connected. Taking the original data from two or three stops back. The newer form.
$node['NodeName'] The same, in the older form. You will meet it in older workflows and templates; it does the same job.
$now / $today The current time / today at 00:00 (a Luxon DateTime, in the workflow timezone). Use it for everything to do with dates and times. DateTime.now() gives you an object of the same type.
$itemIndex This iteration's item and its index in this node's input array (starting at 0). An opening line for the first record, a separator every 10 records, or putting "record N" into a message: {{ $itemIndex + 1 }}.
$workflow Data about the current workflow (.id, .name, .active). Putting the workflow name into a failure notification so it is easy to trace.
$execution Data about this particular run (.id, .mode, .resumeUrl). Adding the execution id to an error message makes it easy to look up later; the Wait node also resumes through .resumeUrl.
$vars / $env Workflow / environment variables (every Cloud edition; on self-hosted, $vars needs Enterprise, and $env needs N8N_BLOCK_ENV_ACCESS_IN_NODE=false). Keeping API endpoints and thresholds in one place instead of rewriting every workflow.

$('NodeName').first(), $('NodeName').last() and $('NodeName').all() are the three common accessors: first/last take one item from the head or the tail, all takes the whole array. $('NodeName').item takes "the item matching the current iteration" (aligned through pairedItem, as Chapter 9 explained).

Common pitfalls

  1. The expression is not evaluated and Slack sends the literal text {{ $json.name }}

    You forgot to flip the field's fx toggle. In Fixed mode {{ }} is ordinary text and is never evaluated. Go back to the node, open that field, switch it to Expression mode in the top right (the background turns dark), and save it again.

  2. The Result shows undefined

    Three possibilities: (a) the field name is misspelled — switch to the Schema view and check the real name against it; dragging is the safest; (b) the upstream node simply does not return that field — look at the actual JSON in its output panel; (c) the case is wrong — Name and name are two different things.

  3. You cannot reach the upstream node: Referenced node doesn't exist

    The node name is wrong. The name inside $('Google Sheets') has to be exactly what the canvas shows — spaces, case and brackets included. The safest move is to copy the node name from the canvas and paste it in rather than typing it yourself.

  4. The date is in the wrong timezone

    $now.toFormat('yyyy-MM-dd HH:mm') showing the wrong zone? Note: open Settings → Timezone in the workflow and choose your location’s IANA timezone; for example, Europe/London. After that $now and $today both follow it. To convert UTC data, state the destination explicitly, for example .setZone('Europe/London').

  5. The preview shows an old value that does not match the actual run

    The expression preview uses the output from the upstream node's last run — if you changed an upstream parameter and have not re-run it, the preview is the old value. Click Execute step upstream to produce fresh output, then come back and look at the preview.

  6. An expression asks an array for .length and gets undefined

    That field is not an array. Switch to the Schema view and confirm its type — if the API returned something that looks like an array but is really a string (for example "[1,2,3]"), you have to turn it into a real array with JSON.parse($json.list) before .length works.

FAQ

Is there a full syntax spec for expressions?
Yes. At run time an expression is a subset of JavaScript + n8n's built-in variables + the Luxon date library. Almost the entire single-expression part of JS works (arithmetic, string methods, array methods, the ternary operator, optional chaining, template literals); but you cannot declare function / const / let, cannot await, cannot require an external package, and cannot write if / for statements. Dates and times go through Luxon (not Moment, not the native Date): use $now, $today, or the global DateTime to build an object (for example DateTime.now() or DateTime.fromISO(...)). For the details see the official docs at docs.n8n.io/code/expressions/ and docs.n8n.io/code/builtin/.
Can I write a for loop or an if inside an expression?
A simple ternary, a ? b : c, is fine, and so is a short || check ($json.x || 'default'). But when you genuinely need a for loop, nested ifs or a complex data transformation, do not force it into an expression — it becomes very hard to read and to debug. Move to the Code node (Chapter 20) instead, write full JavaScript or Python, and get syntax highlighting with it. Expressions suit a dynamic value you can state in one line; the Code node suits work that has logic to run.
Are expressions slow to run?
Usually not. An expression is plain string, date and array work that n8n computes in memory, on the order of microseconds per item. What is genuinely slow is the external API call — even when your expression does almost nothing, a node like Slack or Google Sheet waiting for an API reply is slow. So the answer to "the workflow is slow" is usually the integration nodes from Chapter 11 waiting on an outside service, not the expression.
How do I look up which built-in functions and variables are available?
Once the expression editor is open, a small categorized list slides out on the right (some versions call it helper or variables) listing every available variable that starts with $ ($json, $now, $workflow...) plus the common Luxon methods. Click one of the names and you get its description and a short example. If you cannot find it, go straight to the official docs at docs.n8n.io/code/builtin/, which carry the full list. For Luxon's date methods, see moment.github.io/luxon/.
How is $json.field different from $('Previous Node').item.json.field?
For the node directly upstream (the one wired to yours) what they give you is almost identical — both are the item for the current iteration. The difference is explicitness and stability: $json says "whatever the upstream is, this is the data I just received"; $('NodeName') says "go and take it from that node". When the workflow has branches, or a Merge or Set node dropped in the middle, $('NodeName') lets you skip the intermediate stops and take the original data, so a field rewritten along the way cannot trip you up. Start with $json as a beginner; once workflows get complex you will reach for $('NodeName') naturally.
Can I save an expression as a constant and reuse it?
Expressions themselves have no concept of declaring a variable (you cannot write const x = ...). There are two ways to reuse the same calculation: (a) use a Set node to work it out first and store it in a field, then have every downstream node take it with $('Set').item.json.myVar; (b) use workflow-level Variables (available on Cloud, and on self-hosted Enterprise), defined in Settings and then read anywhere in the workflow with $vars.myVar. In practice (a), the Set node, is the most direct route for a beginner.
Someone's workflow writes $node['Foo'].json.bar — can I change it to $('Foo').item.json.bar?
You can: the two forms are exactly equivalent — n8n keeps the old $node[] form for backward compatibility while promoting the newer $('NodeName'). Use the new one in new projects (it is shorter, and it is what the official docs lead with); there is no need to rewrite old workflows. Mixing them will not break anything, it just looks inconsistent in style. Chapter 6, where you learn to edit templates, is where you will run into the old form most often — being able to read it is enough.