Permission to act as you, and a value to act on
You have wired up Slack, Gmail and Google Sheets. Two questions were never answered properly. What gives a workflow the right to send messages and write spreadsheets as you — that is the credential, one set of account authorization details n8n keeps encrypted. And how does a node put yesterday's order number into today's message — that is the expression, a short piece of code inside {{ }} that n8n evaluates before the node runs. This part covers both, because a node needs both before it does anything useful.
The credential and the workflow are separate things
When you dropped a Slack node onto the canvas in Part 8, n8n made you pick a Credential to connect with. What sits in that dropdown is a credential: one SaaS account authorization that n8n holds for you. Without it the Slack node still throws Authentication failed at run time, even when every other parameter is filled in correctly.
In one sentence, a credential is the set of account authorization details n8n keeps for you, so a node can pull them out automatically when it calls an outside service. Think of your browser's saved passwords, but for workflows. Four properties matter more than anything else you will read about them.
It lives in the n8n database
Not on your computer, and not inside the workflow JSON file. Move a workflow to another n8n and you have to bind the credential again on the other side.
It is stored encrypted
Encrypted with the instance's N8N_ENCRYPTION_KEY environment variable. Not even you can read the plaintext back — only the credential's name and its type.
It is user-level, not workflow-level
A credential you create goes into your personal space by default, and other signed-in users cannot see it. The same account can reuse one credential across many workflows and many nodes.
Deleting one has consequences
Delete a credential and every node that references it turns red and stops working. To share it with other people you go through the sharing mechanism, which has real limits — see section 03.
A node inside a workflow stores only a reference: which credential id I use. The token value itself is not in the workflow file. That is the whole design, and it is why you can hand a workflow JSON to a colleague without handing over anything sensitive.
The workflow is a recipe that says “use the good olive oil from the locked cupboard”. You can email that recipe to anybody. It tells them what to do, it names the cupboard, and it contains no key. They still have to have their own key, or be given one deliberately — and that is exactly the point.
flowchart TD
subgraph W["Safe to hand around"]
direction LR
A["workflow_entity table:
the workflow JSON"] --> B["A node holding only
a credential id"]
end
subgraph C["Never leaves the instance"]
direction LR
D["credentials table"] --> E["An encrypted blob:
token, API key, refresh token"]
F["N8N_ENCRYPTION_KEY
from the environment"] --> E
end
B -.->|"looked up by id at run time"| D
This split buys you two concrete things. Sharing a workflow with a colleague leaks no token. And changing a token — a refresh, a different account — means editing one credential, after which every workflow that references it picks up the new value automatically. You do not go around editing nodes one at a time.
Put the two side by side and the design stops being abstract. They live in the same database, in different tables, and everything else follows from that.
| Question | The credential | The workflow |
|---|---|---|
| Where does it live? | The n8n database, the credentials table, encrypted. |
The n8n database, the workflow_entity table |
| Can it be exported as JSON? | Not recommended on Community — there is a real risk of leaking the plaintext. | Yes. Ctrl+S to save, or Download from the menu |
| How many things can use one copy? | One credential can be reused across many workflows and many nodes by the same user. Sharing it across users depends on Sharing / Projects, which is Enterprise only. | One workflow can reference several different credentials — read the Sheet with A, post to Slack with B |
| What happens if you delete it? | Every node that references it turns red with Credential ID not found. |
Deleting the workflow on its own does not affect the credential. The credential stays |
The export row is the one that catches people out. A workflow comes out of n8n as a file you can put in git, and that is exactly why the token must not be in it.
The Credentials page is at the bottom of the sidebar. In some versions the icon is a little key. Click in and you get a table listing every credential in your workspace with its name, its type and its last-modified time.
Two types cover almost everything
There are hundreds of credential types in n8n. They fall into two groups, and once you recognize those two you can handle almost anything you meet.
| Type | How you authorize | Setup effort |
|---|---|---|
| OAuth2 | Press Connect, you are sent to the provider's site to sign in, click Allow, come back, and the token arrives automatically. Typical services: Google (Gmail, Sheets, Drive, Calendar), Slack, GitHub, Notion, the Microsoft family, HubSpot, Salesforce. | Medium. You need a Client ID and Client Secret, though many built-in credentials come pre-filled |
| API Key / Bearer Token | Create a key in the provider's console, copy it, paste it straight into n8n. Typical services: OpenAI, Anthropic, Airtable, SendGrid, Stripe, Twilio, most developer APIs. | Easy. One field, paste the key |
Two rarer variants exist as well. Do not be alarmed when you meet them. Basic Auth takes a username and password directly, and is common on older internal APIs and some corporate LDAP setups. Header Auth or Custom Auth covers non-standard APIs that want a special field in the header — it is the usual partner for the HTTP Request node from Part 9 when you call an API in the wild.
Hands-on: a Google OAuth2 credential
The most common situation at work: one workflow has to read and write Google Sheets, send Gmail and store files on Drive, all authorized by the same Google account. Walk through it once and you have it for good.
sequenceDiagram participant U as You participant N as n8n credential page participant G as Google consent screen U->>N: Paste the Client ID and Client Secret U->>N: Press Sign in with Google N->>G: Open a popup at the provider U->>G: Pick an account, read the access list, press Allow G-->>N: Redirect back to the OAuth Redirect URL N-->>U: Green check, and Account connected Note over N: The token is written into the credentials table, encrypted
-
Step 1
Credentials, then a new credential
Click Credentials in the sidebar, then Add credential at the top right. On an empty workspace the button in the middle says Create new credential instead. Either way a “Select credential type” search box opens.
-
Step 2
Search for Google, then pick the matching type
Type
googleand a pile of options appears: Google OAuth2 API (the generic one), Google Sheets OAuth2 API, Gmail OAuth2 API, Google Drive OAuth2 API and more.The rule is simple: pick the OAuth2 credential that matches the node you are going to use. It is all Google OAuth underneath, but the scopes differ. For the Gmail node, pick Gmail OAuth2 API.
-
Step 3
Get a Client ID and Client Secret
If this is your first one, n8n asks for a Client ID and a Client Secret. You get both by creating an OAuth 2.0 Client ID at
console.cloud.google.com. The path isCreate a Project → APIs & Services → Credentials → Create Credentials → OAuth client ID → Web application.On that Google form there is an Authorized redirect URIs box. Into it goes the OAuth Redirect URL that the n8n credential page is showing you — something with this shape:
https://your-n8n.example.com/rest/oauth2-credential/callbackRead your own off your own credential page. Do not type the one printed here; the host part is different for every installation. Save the Google form and you have your Client ID and Client Secret.
Or skip this step entirely. WoowTech IT has already built a shared OAuth Client. Ask them for the Client ID and Client Secret rather than opening a new Project of your own. Sharing one keeps quota management and audit logs in a single place. -
Step 4
Paste them into n8n
Back in the credential form, put the Client ID in the Client ID field and the Client Secret in the Client Secret field. Leave Scope at its default in most cases — each credential type ships with the scopes people usually need. Gmail OAuth includes send, read and labels; Sheets OAuth includes read and write.
-
Step 5
Sign in with Google, then Allow
Press Sign in with Google at the top right. Other versions label the same button Connect my account or just OAuth2, so recognize the position rather than the word.
A new window opens on the Google consent screen. Pick your Google account, read the “n8n wants to access your Gmail…” list rather than clicking past it, then press Allow. When the popup closes, the n8n page shows a green check and Account connected.
-
Step 6
Name it so a stranger could identify it
Change the credential name at the top to something recognizable —
Gmail ([email protected])orGoogle Sheets - CS team. Press Save. From now on that name is what appears in the Credential dropdown of your Gmail, Sheets and Drive nodes.
redirect_uri_mismatch, the Authorized redirect URIs on the Google Console side are wrong. They have to match the OAuth Redirect URL shown on the n8n credential page character for character — the https, the domain and the trailing slash included. Compare the two sides and copy it across again rather than guessing at it.Hands-on: an API key credential
The API Key type is far simpler. No bouncing between sites, just one key pasted in. OpenAI as the example:
-
Step 1
Create the key in the provider's console
Sign in at
platform.openai.com/api-keys, press Create new secret key, give it a name such asn8n-woow-prod, and choose All permissions — or read-only, if that is genuinely all you need.The key is shown exactly once. If you do not copy it now you can never get it back, so paste it into n8n immediately rather than into a scratch file you mean to delete later.
-
Step 2
New credential, search for OpenAI
Back in n8n: sidebar
Credentials → Add credential, typeopenaiin the search box, pick OpenAI. -
Step 3
Paste the key and name it
There is a single API Key field. Paste the key. Organization ID can usually stay empty, unless you belong to several OpenAI organizations and have to name one.
Rename the credential to something like
OpenAI (marketing team)and press Save. Done, and much faster than OAuth.
Connection tested successfully means the auth works. It does not mean the scope is enough.A passing test is the doorman recognizing your face. It is not your pass opening the third floor. The building has let you in; whether you can get into the room you actually came for is a different question, and the only way to answer it is to walk up there and try the door.
The key you must not lose
Three questions IT always asks: where does it live, how does the encryption work, and what does a backup have to include. All three in one pass, because the third one is the one that ruins weekends.
| Question | Answer | The part people miss |
|---|---|---|
| Where does it live? | In the n8n database, in a table called credentials (or credentials_entity, depending on version). One row per credential, with columns including id, name, type and data. Whether you self-host on SQLite or on Postgres does not change this. |
The data column is the encrypted JSON blob — token, API key, OAuth refresh token and the rest |
| How is it encrypted? | On startup n8n reads the N8N_ENCRYPTION_KEY environment variable and uses it as the symmetric encryption key, AES from crypto-js underneath. Credentials are encrypted with it on the way into the database and decrypted on the way out. |
If the variable is not set the first time n8n starts, n8n generates one and writes it into the settings file under ~/.n8n — in practice ~/.n8n/config |
| Can the Owner read a token? | No. Even as workspace Owner, the credential detail page shows the token field as blank, meaning saved, or as a row of dots. You never see the real value. One route to the plaintext does exist, and it is worth knowing exactly what it is: reading the database directly with SQL and decrypting the data column with the encryption key. That takes root on the server. So the control stops a curious admin clicking through the UI; it does not stop somebody who already owns the box. |
That is deliberate. It stops insiders, admins included, copying tokens out by accident or on purpose |
In a queue mode deployment, set N8N_ENCRYPTION_KEY by hand and give every worker the same value. Workers that do not share the key cannot decrypt the credentials, and the symptom is a fleet of workers that all fail authentication for no visible reason.
Dumping the database without the encryption key is photographing a safe full of documents and leaving the combination behind. You have a perfect copy of the safe. Every page inside it is still unreadable, and no amount of restoring it again will change that.
~/.n8n/config and the value of the N8N_ENCRYPTION_KEY environment variable together, all three.WoowTech's standard backup routine goes one item further than that minimum, and it is worth copying:
~/.n8n/config file — it holds the encryption key.~/.n8n/binaryData/ attachment directory.N8N_ENCRYPTION_KEY to git. Do not paste it into Slack. Do not leave it in Notion for anyone to read. Once that key leaks, anyone holding a copy of your database dump can decrypt every credential in it. Store it internally at the same level as your database password and your SSH private key.Newer n8n versions offer encryption key rotation, which is worth knowing about before you need it. Set N8N_ENV_FEAT_ENCRYPTION_KEY_ROTATION=true — it works on every self-hosted version. Once it is on, N8N_ENCRYPTION_KEY becomes a master key that protects an inner data key, and Settings → Data Encryption Keys in the UI lets you rotate that inner key. So you can change the credential encryption on a schedule without touching the master key.
The day-to-day life of a credential
Every credential goes through the same stages from creation to deletion. Learn them once and you can run dozens without breaking a sweat.
stateDiagram-v2
[*] --> Created
Created --> Working: saved and tested
Working --> Expired: refresh fails
Expired --> Working: Reconnect
Working --> Deleted: Delete
Deleted --> [*]
note right of Deleted
Every node that referenced it
turns red: Credential ID not found
end note
| Stage | How to do it | Watch out |
|---|---|---|
| Create | Credentials page, Add credential, pick the type, fill the fields, Save | Give it a recognizable name; note who owns it, which team, which environment |
| Edit | Click the credential name on the Credentials page, change the fields, Save. Or open it from a node that has gone red | Every node that references it picks up the new value automatically — you do not edit them one by one |
| Test | The Test button at the top right of the credential detail page. Most types have one; OAuth uses Reconnect instead | A passing test only proves the auth is right, not that the scope matches the node you want to run |
| Reconnect | When an OAuth token refresh fails the credential turns red. Press Reconnect to run the authorization flow again | Most OAuth tokens refresh automatically. You do it by hand after a password change, a revoked scope, or a long gap with no runs |
| Delete | Select the credential on the Credentials page, then Delete | It affects every workflow that references it. Check the Usage tab first to see which workflows depend on it |
When credentials go wrong
The OAuth popup comes back and I am still not signed in
https included, trailing slash included, domain included. Ask IT to compare the two sides and copy it across again.A credential suddenly expires and stops working
A node turns red with Credential ID not found
The Test button returns 401 or 403
The Test passes but the node still will not run
GET /me, for example. Your node may need a more advanced scope or permission than that, and Test never exercises it. Posting to a private Slack channel needs the chat:write.private scope. Writing to a Google Sheet needs the spreadsheets scope, not just spreadsheets.readonly. So Test passes, and the real run throws insufficient_permissions. Reconnect and grant the scope the operation actually needs. The reverse happens too — Test is inaccurate against some APIs, and a rate limit can make the two results disagree. Trust the real run; Test is only a quick smoke test.After a backup restore, no credential opens
N8N_ENCRYPTION_KEY did not come along. The fix: find the old server's encryption key, in ~/.n8n/config or in its environment variables, set the same-named environment variable on the new server, and restart n8n. This is the most common restore disaster there is. Put the key into your backup plan on day one, not after the first incident.The credential I just created is missing from the dropdown
A colleague leaves — what happens to their credentials?
Can a credential be exported as a JSON file?
n8n export:credentials) can export them, but the default is plaintext and the token sits exposed in the file. Put that in git or share it and you have a security incident. If you must export, add --decrypted=false to keep it encrypted, and use it for backup only — do not send it to anyone. If you genuinely need to move credentials between instances, the safest route is rebuilding them by hand in the new environment.Can I put an API key inside a Code node?
Which is safer, OAuth or an API key?
The official documentation lives in three places worth bookmarking: the overview at docs.n8n.io/credentials/, the OAuth configuration detail at docs.n8n.io/hosting/configuration/oauth/, and sharing and permission management at docs.n8n.io/user-management/rbac/. Each credential type also has its own page describing which fields to fill in.
A dynamic fill-in-the-blank, in five forms
Credentials get a node connected. Expressions get data into it. Part 7 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 that downstream node, and the bridge for that is the expression.
Here are everyday jobs you cannot build without one:
- Someone submits an order through a Google Form and Slack posts “New order #1042, customer Chen Xiaoming, amount $3,500”. Those three 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 the day you built it.
- An IF node condition: notify the manager only when the amount is over 1000. The value being compared has to be pulled from the current item's
amountfield. - Adding a row to Google Sheets, where the name column takes the previous node's
nameand the Email column takesemail.
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.
The idea itself is small. Every field inside a node — the Text field on the Slack node, say — can be filled in two ways.
| Mode | What you write, and what happens at run time | Example |
|---|---|---|
| Fixed (static) |
You type directly and n8n sends it through untouched. The same text on every run. | Type hello world in the field and Slack always sends hello world |
| Expression (dynamic) |
Wrap it in {{ }} and write a short piece of JavaScript inside. When n8n reaches the node it evaluates what is inside the braces first, then writes the value back. |
Type {{ $json.name }}; at run time, if the current item's name is Elmo, Slack sends Elmo |
It is the difference between a printed leaflet and a form letter. The leaflet says the same thing to everyone. The form letter has “Dear ______” printed once and a different name written into the gap on every envelope — one template, a thousand letters, and nobody retypes anything.
flowchart TD
A["A field on a node"] --> B{"Fixed or
Expression mode?"}
B -->|"Fixed"| C["The text is sent
exactly as typed"]
B -->|"Expression"| D["n8n evaluates everything
inside the braces first"]
D --> E["The result is written
back into the field"]
E --> F["The node runs
with a real value"]
C --> F
How to switch: at the field label above the field, or on the field itself, there is an Expression / Fixed toggle. Newer builds show a small fx icon plus an Expression tab, so recognize the shape rather than the exact word. Left is Fixed, right is Expression. Once you switch, the field background turns into a gridded code style and a preview block appears underneath. That gridded background is the visual marker that you are in Expression mode.
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 braces section gets replaced. That is the most intuitive way to build a template string, and you will use it constantly.
+, .toUpperCase(), the ternary a ? b : c. What you should not do is run heavy logic or call an API in there. That is the job of the Code node, which Part 15 covers.The five 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 you write it | What it means | When to use it |
|---|---|---|
{{ $json.field }} |
A field on the current item. | 90% of cases. The order number into a Slack message, the customer name into a Gmail subject, the email into a new Sheets row |
{{ $('Google Sheets').item.json.field }} |
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 |
{{ $now }} |
The current time, as a Luxon DateTime object in the workflow timezone. Equivalent to DateTime.now(). |
A date in a Gmail subject, a timestamp in a new Sheets row, arithmetic against another date |
{{ $json.items.length }} |
The length of an array. | A Slack notification saying how many orders there are, or an IF node checking whether there are more than 10 |
{{ $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 $() are built-in variables that n8n injects for you. They are not native JavaScript; n8n prepares them and you simply use them. Part 15 spells out the full list — for now, get comfortable with these few.
{{ $('Google Sheets').item.json.field }} is the newer form, promoted since 0.190 and later. The older form for the same job is {{ $node['Google Sheets'].json.field }}. Both still run, because n8n keeps the old one for backward compatibility. On alignment: $('Node').item explicitly goes through pairedItem to align with the current iteration, and $node['Node'].json also takes the matching item from the current run — the form is missing the .item layer, but the behavior is equivalent. Seeing both mixed in somebody else's workflow is normal.Build them by dragging instead of typing
Honestly, writing an expression almost never means typing code yourself. The expression editor has a good drag mechanism, so you do not have to remember any field name — one pull with the mouse and it is written correctly. This is the one trick every beginner should learn first, because dragging never misspells.
-
Step 1
Run the upstream node once
Click Execute step on the upstream node — the small play button underneath it — so it produces output. Without output, the expression editor does not know which fields exist and shows an empty schema.
-
Step 2
Open the downstream node
Double-click the node to open its settings panel and find the field you want to change. The Text field on a Slack node is the usual first one.
-
Step 3
Switch the field into Expression mode
Above the field there is a Fixed / Expression tab, or a small
fxicon. Click the Expression side. The field background turns into a gridded code style, which is how you know the switch worked. -
Step 4
Drag a field in from the INPUT panel
Once the expression editor opens, an INPUT panel slides out on the left listing every field from the upstream node. You can switch it between Table, JSON and Schema views. Drag a field straight into the expression box and n8n writes the correct form for you — drag
nameand you get{{ $json.name }}. -
Step 5
Check the Result preview
Below the expression box is a Result: line showing, live, the value this expression evaluates to. The value you expected means you are done. Red text or an empty result means the expression has a problem, and section 07 is about reading those.
-
Step 6
Type static text around it
You can add static text before or after the expression you dragged in. If the box holds
{{ $json.name }}and you typeHiin front, it becomesHi {{ $json.name }}and the preview updates immediately toHi Elmo.
The two things you will format most
Almost every expression you write beyond {{ $json.field }} is either a date or a string. Both have a small set of moves worth memorizing.
Dates and times: enter Luxon
n8n expressions handle time with Luxon, a JavaScript 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 you write it | Result | When to use it |
|---|---|---|
{{ $now }} | Now, as a Luxon DateTime object with timezone. | Timestamps, Gmail subjects, recording when a 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 Sheets 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 — and you must 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.
Settings → Timezone in the workflow and choose the IANA timezone for your location, for example Europe/London. After that $now and $today both use that zone. For cross-timezone data, state the destination explicitly with something like .setZone('Europe/London').Joining strings: three styles, one result
Slack messages, Gmail subjects and Sheets columns are all strings. To drop an upstream field into a sentence, n8n gives you three styles. They all achieve the same thing, so pick whichever reads best to you.
Hi {{ $json.name }} evaluates to Hi Elmo. The one to prefer — as long as what sits outside the braces is static text, mixing directly is the shortest.{{ 'Hi ' + $json.name }} evaluates to Hi Elmo. Use it when you have to build the string inside the braces, for example to wrap the whole thing in toUpperCase().{{ `Hi ${$json.name}, order ${$json.orderId}` }} evaluates to Hi Elmo, order 1042. Cleanest when one expression has to interpolate several variables with punctuation between them.The string methods are all standard JavaScript, so nothing here is n8n-specific:
| Method | What it does | Example |
|---|---|---|
.toUpperCase() | Upper-cases everything. | {{ $json.code.toUpperCase() }} gives ABC123 |
.toLowerCase() | Lower-cases everything. | {{ $json.email.toLowerCase() }} |
.trim() | Strips leading and trailing whitespace. | Use it to clean up the padded name you got out of a Google Form |
.slice(0, 100) | Takes the first 100 characters. | {{ $json.body.slice(0, 100) }} keeps a Slack preview from blowing up |
.replace('old', 'new') | Replaces text. | {{ $json.phone.replace(/-/g, '') }} removes every hyphen |
.split(',') | Splits into an array. | Turns a,b,c into ['a','b','c'] |
{{ $json.name?.toUpperCase() }}. Adding the ? means that when name does not exist you get undefined back instead of a thrown error — far tidier than an if check, which expressions cannot run anyway.Cases you can copy
These are the ones that come up over and over. Open the workflow, switch the field to Expression mode, and copy them as they are written.
| Scenario | Expression | What it means |
|---|---|---|
| Slack 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 Sheets row, name column | {{ $('Webhook').item.json.name }} |
Explicitly reaches back for name on the Webhook node, so it does not break even if an Edit Fields (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 condition: amount over 1000 | Left value {{ $json.amount }}, operator >, right value 1000 |
The IF node does not take a raw boolean. The operator is 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 an Edit Fields (Set) node in front to work the boolean out first |
| Dynamic HTTP Request URL | https://api.example.com/orders/{{ $json.orderId }} |
URLs support mixed expressions too, so the order id goes straight into the path |
| Turn an 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 |
| Use a value, or fall back | {{ $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 }} |
Four kinds of red text, and what each one means
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 in seconds. Four kinds cover almost everything.
flowchart LR A["The Result line is red,
or not the value you wanted"] --> B{"What does
it say?"} B -->|"undefined"| C["The field name is wrong.
Check the Schema view, or drag it in"] B -->|"Cannot read property"| D["The parent is empty. Use optional
chaining, or fix the upstream node"] B -->|"Referenced node does not exist"| E["Copy the node name off the
canvas, spaces and case included"] B -->|"a schema, no real values"| F["Click Execute step upstream
so it produces real output"]
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. Switch the INPUT panel to the Schema view and confirm how the field is really written: case, spaces, and whether it is nested like data.name. Or just drag it in, because dragging never misspells.
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 off it. Two fixes — sidestep it with optional chaining, $json.customer?.email, or fix the upstream node first and confirm it really returns customer.
Result shows [Referenced node doesn't exist]
You wrote $('Google Sheets') or $node['Google Sheets'], but there is no node by that name on the canvas. Usually the node was renamed, or the case or the spacing does not match. Go back to the canvas, see what the node is actually called, and copy that name across rather than typing it.
Result shows a schema instead of values
The preview shows the field structure but no real values. That means the upstream node has not run yet, so n8n does not know what it will fetch. Click Execute step once on the upstream node and Result will have real values when you come back.
Pitfalls that are not error messages
Some failures are quieter than a red border, and these are the ones that waste an afternoon.
{{ $json.name }}. You forgot to flip the field's fx toggle. In Fixed mode the braces are ordinary text and are never evaluated. Open the field, switch it to Expression mode, and save again.undefined and the spelling looks right. Three possibilities: the field name is misspelled after all (check it against Schema view); the upstream node simply does not return that field (look at the actual JSON in its output panel); or the case is wrong, because Name and name are two different things..length and gets undefined. That field is not an array. Switch to Schema view and confirm its type — if the API returned something that looks like an array but is really a string, such as "[1,2,3]", you have to turn it into a real array with JSON.parse($json.list) before .length works.The full variable list, for when you meet one in the wild
Seeing $json, $node and $input mixed in somebody 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.
| Variable | What it refers to | When 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 both parts 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 for a count, or $input.all().map(i => i.json.amount).reduce(...) to total one field across every item. Available in expressions and in the Code node |
$input.first() / $input.last() | The first or last item this node received. | Taking the head or tail of a batch for a summary, such as $input.first().json.startedAt |
$('NodeName') | The output of any upstream node, not only the one directly connected. The newer form. | Taking the original data from two or three stops back |
$node['NodeName'] | The same thing, in the older form. | You will meet it in older workflows and templates. It does the same job |
$now / $today | The current time, or today at 00:00. A Luxon DateTime in the workflow timezone. | Everything to do with dates and times. DateTime.now() gives an object of the same type |
$itemIndex | This iteration's index in the node's input array, starting at 0. | An opening line for the first record, a separator every 10, or putting “record N” into a message with {{ $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 and 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 |
Three common accessors go with the node forms: $('NodeName').first() and $('NodeName').last() take one item from the head or the tail, and $('NodeName').all() takes the whole array. Alongside them, $('NodeName').item takes the item matching the current iteration, aligned through pairedItem as Part 7 explained.
Questions people ask once and then stop asking
Is there a full syntax spec for expressions?
function, const or let, cannot await, cannot require an external package, and cannot write if or for statements. Dates go through Luxon — use $now, $today, or the global DateTime to build an object, for example DateTime.now() or DateTime.fromISO(...). The official docs are 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 ? b : c, is fine, and so is a short || check such as $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, which Part 15 covers, where you 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?
How do I look up which built-in functions are available?
$ — $json, $now, $workflow and the rest — plus the common Luxon methods, and clicking a name gives you its description and a short example. If you cannot find it there, the full list is in the official docs at docs.n8n.io/code/builtin/, and Luxon's own date methods are documented at moment.github.io/luxon/.How is $json.field different from $('Previous Node').item.json.field?
$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 Edit Fields (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?
const x = .... There are two ways to reuse the same calculation. Use an Edit Fields (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. Or use workflow-level Variables, available on Cloud and on self-hosted Enterprise, defined in Settings and read anywhere in the workflow with $vars.myVar. In practice the Set node is the most direct route for a beginner.Someone's workflow uses $node['Foo'].json.bar — should I change it?
$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 — but there is no need to rewrite old workflows. Mixing them will not break anything, it just looks inconsistent. Part 4, where you learn to edit somebody else's workflow, is where you will run into the old form most often. Being able to read it is enough.Where to go from here
Your nodes can connect, and they can read data. Now teach them to choose.
Part 11 starts flow control: the IF node and the Switch node, which take the expressions you just learned and turn them into branches — one path when the amount is over 1000, another when it is not — and then Merge, for putting the branches back together again.
Open the full guidePart 10 of the Woow n8n Onboarding Guide series on the Apporo blog.
Adapted from the Woow n8n Onboarding Guide, produced by WoowTech and released under CC BY 4.0. This adaptation is published by Apporo under the same licence.
Light · Air · Water · Control · apporo