n8n expressions:
$json, $now, real patterns, common mistakes.
I have expressions in probably 80% of my workflow nodes. For three months I got them wrong in ways that took forever to debug because the errors weren't loud. Here's what I wish I'd had before I started guessing.
I have expressions in probably 80% of my workflow nodes. Email subject lines, Telegram message bodies, field normalization before a CRM write, conditional defaults for empty fields. They're everywhere. I've also gotten them wrong in ways that take a while to debug because the errors aren't always loud. What's below is what actually works in my production stack. Not a summary of the docs.
Expressions are just JavaScript inside double curly braces. That's the whole mechanism.
An n8n expression is any value wrapped in double curly braces: {{ }}. Whatever you put inside those braces gets evaluated as JavaScript at runtime. The result replaces the expression in the field.
That's the whole mechanism. Any node field that shows the expression toggle (the little lightning bolt icon) can accept an expression instead of a static value. You're not limited to pulling fields from the previous node. You can do math, string operations, date formatting, conditional logic, optional chaining, anything JavaScript supports without async calls.
The key word there is inline. Expressions run inside a single field. They don't loop over items, they don't build arrays from scratch, and they can't await anything. One input value goes in, one transformed output comes out. When you need more than that, you need a Code node. I'll cover exactly where that line is later.
Each expression is evaluated separately for each incoming item. If your workflow processes 50 emails, the expression in your "Set subject" field runs 50 times, once per email, with that email's data in scope.
$json covers 90% of what you'll need. Here's what to reach for with the other 10%.
n8n gives you a set of built-in variables you can reference inside any expression. These are the ones I actually use:
$json
$json is the data from the current item. It's the JSON output of whatever node is directly upstream. If a Webhook node receives { "email": "hello@example.com", "name": "Alice" }, then inside the next node's fields, {{ $json.email }} gives you hello@example.com.
This is the variable you'll use in 90% of expressions. When people say "n8n $json variables," this is what they mean.
$input
$input gives you more control over which item you're referencing when a node receives multiple items. $input.item.json is equivalent to $json for the current item. $input.first().json always gets the first item in the input set, regardless of which item is being processed. $input.last().json gets the last one. $input.all() returns the full array. Once you're iterating over an array, though, you're close to needing a Code node anyway.
$node["NodeName"].json
This one pulls data from a specific named node anywhere in the workflow, not just the immediately upstream one. {{ $node["HTTP Request"].json.id }} grabs the id field from the output of a node called "HTTP Request", even if several nodes ran between that node and the current one.
I use this when I need data from a trigger node later in the workflow. The Webhook trigger fires, I do 5 processing steps, and at step 6 I still need the original payload. Instead of passing it through every node manually with Set nodes, I just reference $node["Webhook"].json.originalField directly.
$now and $today
$now is a Luxon DateTime object representing the current moment, date and time with timezone. $today is the same thing snapped to midnight: just the date, no time component.
Both use Luxon's API, so you chain methods off them. $now.format("MMMM D, YYYY") gives you something like "March 21, 2026". $now.toISO() gives you an ISO 8601 string. $now.minus({ days: 7 }).toISODate() gives you last week's date.
$vars
$vars holds workflow-level variables — values you define once and reference across multiple nodes. {{ $vars.slackChannel }} pulls from whatever you've defined in the Variables panel. Useful for config values you don't want hardcoded everywhere: API base URLs, environment flags, threshold values.
$workflow
$workflow gives you metadata about the running workflow itself: $workflow.id, $workflow.name, $workflow.active. I mostly use $workflow.name when building error notifications so the alert message tells me which workflow failed, not just that something broke somewhere.
Four patterns that show up in almost every workflow I write
Access a field
{{ $json.email }}
{{ $json.user.name }}
{{ $json.items[0].title }}
Dot notation for objects, bracket notation for arrays. [0] is the first item. These are plain JavaScript property accesses on the parsed JSON object, so standard JS rules apply.
Format a date
{{ $now.format("MMMM D, YYYY") }}
{{ $now.toISODate() }}
{{ $now.minus({ days: 30 }).toISODate() }}
{{ DateTime.fromISO($json.createdAt).format("MMM D") }}
Luxon is available inside expressions as DateTime if you need to parse a date string from a field. $now and $today are already Luxon objects, so no parsing step needed.
Concatenate strings
{{ `Hello, ${$json.name}! Your order #${$json.orderId} is confirmed.` }}
{{ `Weekly Report — ${$now.format("MMMM D, YYYY")}` }}
Template literals (backtick strings) are the cleanest way to build strings with variables. The ${} inside the backticks is standard JavaScript interpolation. Much more readable than concatenating with + signs, especially in longer strings.
Default value for empty fields
{{ $json.company ?? "Unknown Company" }}
{{ $json.phone ?? "No phone provided" }}
{{ $json.tier ?? "free" }}
The ?? operator (nullish coalescing) returns the right side if the left side is null or undefined. It does NOT trigger for empty string "" or 0, which is usually what you want. If you're handling a field that might come through as an empty string and you want to default it, use $json.field || "default" instead, because || treats empty string as falsy.
Three expressions from my actual production stack (not made-up examples)
Email subject lines with live dates
Every automated email I send has a date in the subject. Static text would go stale the moment the workflow runs outside the day I wrote it. So the subject line field looks like this:
{{ `Your Weekly Report — ${$now.format("MMMM D, YYYY")}` }}
That gives me "Your Weekly Report — March 21, 2026" and never goes stale. The workflow runs every Monday, the date is always correct, I never have to touch it.
Normalizing email inputs
Webhook data from forms is messy. People type "HELLO@EXAMPLE.COM" or leave trailing spaces. Before I write anything to the CRM, I normalize the email:
{{ $json.email?.toLowerCase().trim() }}
The ?. is optional chaining. If $json.email is undefined or null, the whole expression evaluates to undefined instead of throwing a TypeError. Then toLowerCase() and trim() clean up whatever came through.
Building a Telegram message
When a high-value Stripe payment fires, my Telegram alert needs to be readable at a glance. Template literals make this clean:
{{ `💰 New payment\n\nAmount: $${($json.data.object.amount / 100).toFixed(2)}\nCustomer: ${$json.data.object.customer_email ?? "Unknown"}\nTime: ${$now.format("h:mm a")}` }}
Dividing by 100 converts Stripe's cents to dollars. toFixed(2) ensures two decimal places. The ?? handles cases where customer_email is absent. This all happens in one expression, no Code node required.
When to stop using expressions: five clear signals it's time for a Code node
Expressions handle single-value transforms. That covers a huge amount of real use cases. But there are clear situations where pushing logic into an expression is the wrong call:
- Looping over arrays to produce multiple items. Expressions can't
returnmultiple items or reshape the item count. The Code node can. If you're mapping over an array of results and need each one to become its own workflow item, that's a Code node job. - Complex conditional chains. A simple ternary in an expression is fine:
{{ $json.status === "active" ? "Enabled" : "Disabled" }}. Nested ternaries with 4 conditions are a different story. They become unreadable fast and nearly impossible to debug. Write a Code node with properif/elseblocks. - Anything that needs
await. Expressions are synchronous. You cannotawaita fetch, a database call, or anything async inside{{ }}. That's what HTTP Request nodes and Code nodes are for. - Building data structures from scratch. If you're constructing a complex object or array that doesn't exist in the input, a Code node is cleaner and easier to test.
- Logic you need to re-use across nodes. You can't define a function once and call it from multiple expressions. If you find yourself copying the same expression into 6 nodes, put it in a Code node and have downstream nodes pull from its output.
The rule I use: if the expression fits on one line and reads clearly, use it. If it doesn't, Code node.
Expressions are not for complex logic. They're for simple, inline, one-value transforms. Respecting that line keeps your workflows debuggable by someone who didn't write them.
Five expression mistakes, in order of how often I see them in debugging threads
Forgetting the $json prefix
This is the most common one, especially if you're used to other automation tools. You type {{ email }} and expect it to work. It doesn't. email is not in scope by itself inside an n8n expression. The field is at $json.email. That $json. prefix is required every time.
// Wrong — "email" is not defined
{{ email }}
// Right
{{ $json.email }}
Optional chaining where it isn't needed
Optional chaining (?.) is useful when a field might not exist. But I see people adding it to everything out of habit: {{ $json?.email?.toLowerCase?.() }}. This adds visual noise and obscures bugs. If $json doesn't exist, that's a legitimate error you want to see, not silently swallow. Use ?. where you genuinely expect null or undefined, not as a blanket error suppressor.
Trying to use await
Expressions can't do async work. {{ await fetch("...") }} will throw. There's no workaround inside an expression. n8n evaluates them synchronously. If you need to call an API mid-workflow, use an HTTP Request node. If you need custom async logic, use a Code node, which does support async/await in its JavaScript mode.
Type confusion with string numbers
A field containing "42" (string) and a field containing 42 (number) look identical in the n8n data panel and behave differently in expressions. If you're doing math on a field that came through a webhook as a string, you'll get wrong results silently. Wrap it: {{ Number($json.count) * 2 }} or {{ parseInt($json.count) }}.
Referencing node names that have spaces or special characters
If your node is named "HTTP Request 1", $node["HTTP Request 1"].json works fine. But if you rename it "HTTP Request #1" or use colons or quotes in the name, you can get parse errors inside the expression. Keep node names simple and consistent. It saves real pain when you reference them by name later.
The Expression Editor preview pane debugs faster than any other method
When something isn't working, the first place to look is the Expression Editor. Click the expression toggle (lightning bolt) on any field, then open the full editor. On the right side of the editor, you'll see a live preview showing exactly what the expression evaluates to with the current item's data.
This is real-time. You type changes to the expression and the preview updates immediately. It shows the resolved value, or it shows the error. Usually that error is "cannot read properties of undefined" when you're chaining into a field that doesn't exist on this particular item.
If you're not sure your expression is correct, open the editor on a node that's already received data from a test run. The preview shows you the real output with real data before you commit to wiring the node and running again. Far faster than the run-check-fix-run cycle.
One thing to know: the preview shows the result for whichever item is currently selected in the input panel. If your workflow processes multiple items and you're worried the expression breaks on item 3 but not item 1, click through the items in the input panel and watch the preview change. The bug usually shows up when you switch to the item that has a missing or differently-typed field.
Expressions become invisible once you know them. That's the point.
Every workflow in my n8n pack uses expressions throughout. The lead capture workflow normalizes the email before the CRM write. The email triage workflow builds the Telegram alert body with template literals. The payment hook formats the Stripe amount and constructs the notification in a single expression field. None of those needed a Code node. They're all inline transforms that expressions handle cleanly in one line.
Expressions seem like a detail until you realize you're writing them in every single node. Learning them properly takes about an afternoon. After that, they stop being friction and start being the fast path. The investment is small. The payoff is permanent. Every n8n workflow you build from now on gets better because of it.
See every expression used across 14 production workflows
The n8n Automation Starter Pack includes 14 annotated workflows for $97 one-time, so you can see how expressions are actually used in lead capture, triage, payments, and more, right inside real node fields.
See Database Suite MCP →One-time $97 · Instant download · 30-day money-back guarantee