n8n-expressions-official

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

n8n Expressions

n8n 表达式

n8n's expression language is JavaScript embedded in
{{...}}
blocks. They run synchronously, on a single item at a time (
$json
), with access to upstream nodes (
$('Name')
), Luxon for dates, and most of native JS.
n8n的表达式语言是嵌入在
{{...}}
块中的JavaScript。它们同步运行,一次处理单个项目(
$json
),可访问上游节点(
$('Name')
)、用于日期处理的Luxon库,以及大部分原生JS功能。

Non-negotiable

必须遵守的规则

Reference data by node name, not
$json
.
Use
$('Node Name').item.json.field
(or
.first().json.field
).
$json
works but breaks when any node clears item context (Aggregate, Code with Run for All, branching merges) or a refactor adds an intermediate. Failures are silent, and downstream gets the wrong data with no error. Node-name references are stable.
通过节点名称引用数据,而非
$json
使用
$('Node Name').item.json.field
(或
.first().json.field
)。
$json
虽然可用,但当任何节点清除项目上下文(如Aggregate、启用Run for All的Code节点、分支合并)或重构添加中间节点时会失效。这类故障是静默的,下游会获取错误数据但无报错信息。通过节点名称引用则更稳定。

Strong defaults

推荐默认做法

  • No Set nodes whose only purpose is to feed a single downstream field. Inline the expression at the consumer. Set earns its place when 2+ consumers read the same derived value, the derivation is non-trivial, or the Set is a sub-workflow's final return-shaper. See "The Set-node antipattern" below.
  • Luxon for dates, not the DateTime node. Date math, formatting, and parsing all work in expressions:
    {{ DateTime.now().minus({ days: 7 }).toISO() }}
    . The DateTime node is more visible on the canvas for beginner human users, but avoid it unless the user specifically asks for it.
  • Expressions over extra nodes generally. Build the email body in the email node's body field, and compute the URL in the HTTP Request's URL field. Reach for an extra node when the transform is reused or the primary purpose of a section.
  • Multi-line expressions are indented and commented. When an expression spans more than one line, format it like real code. Most n8n users are not coders, so explain the code with concise inline comments
  • 不要使用仅为单个下游字段提供数据的Set节点。 直接在消费节点中内联表达式。仅当有2个及以上消费者读取同一派生值、推导逻辑复杂,或Set节点是子工作流的最终返回结果塑形器时,Set节点才具备存在价值。详见下文“Set节点反模式”。
  • 使用Luxon处理日期,而非DateTime节点。 日期运算、格式化和解析都可通过表达式完成:
    {{ DateTime.now().minus({ days: 7 }).toISO() }}
    。DateTime节点对新手用户来说在画布上更显眼,但除非用户明确要求,否则应避免使用。
  • 优先使用表达式而非额外节点。 在邮件节点的正文字段中直接构建邮件内容,在HTTP请求节点的URL字段中直接计算URL。仅当转换逻辑需要复用,或某段流程的核心目的就是该转换时,才使用额外节点。
  • 多行表达式需缩进并添加注释。 当表达式超过一行时,按照规范代码格式排版。大多数n8n用户并非开发者,因此需用简洁的行内注释解释代码逻辑。

Why reference by node name (
$('Name').item.json.x
) over
$json.x

为何优先通过节点名称(
$('Name').item.json.x
)而非
$json.x
引用数据

$json
means "the current item flowing into this node." Fine when the node is directly downstream of one source and nothing has cleared the item context (some nodes do: Aggregate, Code with
Run for All
, branching merges).
It breaks when:
  • You insert a node between source and consumer (the consumer was reading
    $json.x
    from a node 3 steps back, and now the new intermediate node is what
    $json
    refers to).
  • A node clears the context (Aggregate, certain merges, Code nodes that don't preserve shape).
  • Branches converge via Merge.
    $json
    is whichever branch fired last, not deterministic.
$('Get User').item.json.id
is unambiguous. Always the named node's first-item JSON, regardless of what's between.
The exception that makes the rule:
When branches converge and you need a stable reference point, insert a NoOp node at the convergence. Name it descriptively (e.g.,
Combine Inputs
). Downstream nodes reference it by name.
Branch A ──┐
           ├─→ [NoOp: Combine Inputs] ──→ Downstream nodes use $('Combine Inputs').item.json.x
Branch B ──┘
NoOp survives refactors: inserting a transform between Combine Inputs and the consumer doesn't break the
$('Combine Inputs')
reference.
This pattern is required when downstream nodes need data from a node whose context gets cleared by an intermediate operation.
If the branches produce different shapes, use a Set node instead of NoOp. NoOp passes through whatever shape arrived, so downstream still has to know which branch fired. A Set node normalizes both branches into one shape, and downstream reads one set of fields:
ts
// Set node: "Normalize Inputs"
name: `={{ $('Lookup by Email').item.json.name || $('Lookup by ID').item.json.full_name }}`
email: `={{ $('Lookup by Email').item.json.email || $('Lookup by ID').item.json.contact_email }}`
Downstream nodes reference
$('Normalize Inputs').item.json.name
regardless of which branch produced it.
$json
代表“流入当前节点的当前项目”。当节点直接位于单一数据源下游,且无任何节点清除项目上下文时(部分节点会清除上下文:Aggregate、启用
Run for All
的Code节点、分支合并),这种方式可行。
但在以下场景会失效:
  • 在数据源和消费节点之间插入新节点(消费节点原本读取3步之前节点的
    $json.x
    ,现在
    $json
    指向新的中间节点)。
  • 某节点清除了上下文(如Aggregate、某些合并节点、未保留数据结构的Code节点)。
  • 分支通过Merge节点汇合。
    $json
    指向最后触发的分支,结果不确定。
$('Get User').item.json.id
的引用是明确的。无论中间有何节点,它始终指向指定节点的第一个项目的JSON数据。
规则的例外情况:
当分支汇合且需要稳定的引用点时,在汇合处插入一个NoOp节点。为其命名(如
Combine Inputs
)。下游节点通过名称引用该节点。
分支A ──┐
           ├─→ [NoOp: Combine Inputs] ──→ 下游节点使用 $('Combine Inputs').item.json.x
分支B ──┘
NoOp节点在重构时不受影响:在Combine Inputs和消费节点之间插入转换节点不会破坏
$('Combine Inputs')
的引用。
当下游节点需要从某个上下文被中间操作清除的节点获取数据时,必须使用此模式。
如果分支产生不同的数据结构,使用Set节点而非NoOp节点。 NoOp节点会直接传递传入的数据结构,因此下游仍需判断触发的是哪个分支。Set节点可将两个分支的数据结构标准化为统一格式,下游只需读取一组字段:
ts
// Set节点:"Normalize Inputs"
name: `={{ $('Lookup by Email').item.json.name || $('Lookup by ID').item.json.full_name }}`
email: `={{ $('Lookup by Email').item.json.email || $('Lookup by ID').item.json.contact_email }}`
下游节点无论哪个分支产生数据,都只需引用
$('Normalize Inputs').item.json.name

The Set-node antipattern

Set节点反模式

The pattern AI agents often produce:
Webhook → Set: { customer_id: $json.body.customer_id, amount: $json.body.amount }
       → Postgres: WHERE id = {{ $json.customer_id }}
       → Email: Total is {{ $json.amount }}
The Set node does nothing useful. Each downstream node could read from the webhook directly:
Webhook → Postgres: WHERE id = {{ $('Webhook').item.json.body.customer_id }}
       → Email: Total is {{ $('Webhook').item.json.body.amount }}
The Set node only earns its place if:
  • The same derived value is used by multiple downstream consumers (derivation non-trivial).
  • The derivation is logic-heavy and a name aids readability.
  • Multiple branches need the same shape, and a shared upstream reference is cleaner.
  • It's the final node of a sub-workflow, shaping the return contract. Explicit exception: the "single consumer" is every caller, so the Set is the API boundary. Optional but encouraged for sub-workflows, and sometimes required when the prior node carries noise fields. See
    n8n-subworkflows-official
    .
  • You need to drop fields from the item by setting
    Include Other Fields: false
    .
    Set is the cleanest way to whitelist an output shape. This is the underlying mechanism behind the sub-workflow return-shaper bullet above (preventing internal scratch fields from leaking to callers), but it applies anywhere you need a clean shape downstream.
  • You need to rename fields. A Set keeps the rename visible in one place rather than spread across every consumer expression.
For "extract a field from the request body and use it once," no Set node. The expression goes in the consuming field.
For "extract once for many downstream uses," a Set node is legitimate. If only one consumer uses it, the Set is debt (except the return-shaper case above).
AI助手常生成的模式:
Webhook → Set: { customer_id: $json.body.customer_id, amount: $json.body.amount }
       → Postgres: WHERE id = {{ $json.customer_id }}
       → Email: Total is {{ $json.amount }}
该Set节点毫无用处。每个下游节点都可直接从Webhook读取数据:
Webhook → Postgres: WHERE id = {{ $('Webhook').item.json.body.customer_id }}
       → Email: Total is {{ $('Webhook').item.json.body.amount }}
仅在以下场景,Set节点才具备存在价值:
  • 同一派生值被多个下游消费者使用(且推导逻辑复杂)。
  • 推导逻辑复杂,命名有助于提升可读性。
  • 多个分支需要统一的数据结构,共享上游引用更简洁。
  • 作为子工作流的最终节点,定义返回契约。 明确例外情况:“单个消费者”是所有调用者,因此Set节点是API边界。子工作流中可选但推荐使用,当前节点携带冗余字段时有时是必需的。详见
    n8n-subworkflows-official
  • 需要通过设置
    Include Other Fields: false
    来删除项目中的字段。
    Set节点是白名单输出结构的最简洁方式。这是上述子工作流返回塑形器的底层机制(防止内部临时字段泄露给调用者),但适用于任何需要下游使用干净数据结构的场景。
  • 需要重命名字段。 Set节点可将重命名操作集中在一处,而非分散到每个消费者的表达式中。
对于“从请求体提取字段并仅使用一次”的场景,不要使用Set节点。直接在消费字段中使用表达式即可。
对于“提取一次供多个下游使用”的场景,Set节点是合理的。如果只有一个消费者使用该值,Set节点就是冗余的(上述返回塑形器场景除外)。

Quick test for whether a Set node is needed

判断是否需要Set节点的快速测试

How many downstream nodes reference each field?
  • 0 or 1 → delete, inline the expression.
  • 2+ → may earn its place, especially if non-trivial.
Multiple consecutive Set nodes are almost certainly over-extraction. Collapse.
每个字段被多少个下游节点引用?
  • 0或1个 → 删除Set节点,内联表达式。
  • 2个及以上 → 可能具备存在价值,尤其是当推导逻辑复杂时。
多个连续的Set节点几乎肯定是过度提取。应合并它们。

What expressions can do

表达式能实现的功能

Single-field transformation

单字段转换

ts
{{ $json.name.toUpperCase() }}
{{ $json.email.toLowerCase().trim() }}
{{ $json.items.length }}
{{ $json.user.first_name + ' ' + $json.user.last_name }}
{{ `(${$json.user.phone.slice(0, 3)}) ${$json.user.phone.slice(3, 6)}-${$json.user.phone.slice(6, 10)}` }}
ts
{{ $json.name.toUpperCase() }}
{{ $json.email.toLowerCase().trim() }}
{{ $json.items.length }}
{{ $json.user.first_name + ' ' + $json.user.last_name }}
{{ `(${$json.user.phone.slice(0, 3)}) ${$json.user.phone.slice(3, 6)}-${$json.user.phone.slice(6, 10)}` }}

Method chains:
.map()
,
.filter()
,
.find()
,
.reduce()

方法链:
.map()
.filter()
.find()
.reduce()

Array methods are some of the most useful expression tools. They replace dozens of nodes.
ts
{{ $json.tags.filter(tag => tag.active).map(tag => tag.name).join(', ') }}
{{ Object.values($json.scores).reduce((sum, score) => sum + score, 0) }}

// Find one matching item from another node's output
{{ $('Get Models').all().find(model => model.json.id === $json.modelId).json.modelName }}

// Filter array, then check shape
{{
  $('Get User\'s Entries').all()
    .map(item => item.json)
    .filter(entry => entry.prize_eligible === 'eligible')
    .length > 0
}}
数组方法是最有用的表达式工具之一,可替代数十个节点。
ts
{{ $json.tags.filter(tag => tag.active).map(tag => tag.name).join(', ') }}
{{ Object.values($json.scores).reduce((sum, score) => sum + score, 0) }}

// 从另一个节点的输出中查找匹配的项目
{{ $('Get Models').all().find(model => model.json.id === $json.modelId).json.modelName }}

// 过滤数组,然后检查结构
{{
  $('Get User\'s Entries').all()
    .map(item => item.json)
    .filter(entry => entry.prize_eligible === 'eligible')
    .length > 0
}}

Always indent multi-step chains and add comments

多步骤方法链需始终缩进并添加注释

When a chain has 2+ method calls or non-obvious filter logic, format it across lines and comment. Readers may not be the author, so comments make intent legible to non-technical readers too.
ts
{{
  // Find all entries that are still processing AFTER 1 hour
  // (used to allow re-submission since something likely went wrong)
  $('Get User\'s Entries').all()
    .map(item => item.json)
    .filter(entry =>
      entry.prize_eligible === 'processing' &&
      $now.diffTo(entry.created_at, 'minutes') > 60
    )
    .length > 0
}}
This kind of logic is common in routing nodes (Switch, IF). Un-commented, it's unreadable for most users.
当方法链包含2个及以上方法调用或非直观的过滤逻辑时,应跨行排版并添加注释。读者可能并非代码作者,注释可让非技术读者也能理解意图。
ts
{{
  // 查找所有处理时间超过1小时的条目
  // (用于允许重新提交,因为可能出现了问题)
  $('Get User\'s Entries').all()
    .map(item => item.json)
    .filter(entry =>
      entry.prize_eligible === 'processing' &&
      $now.diffTo(entry.created_at, 'minutes') > 60
    )
    .length > 0
}}
这类逻辑在路由节点(Switch、IF)中很常见。如果没有注释,大多数用户无法读懂。

.all().map()
triggers an "execute once" question

.all().map()
会触发“执行一次”的问题

When you use
$('Source Node').all().map(...)
(or
.filter()
,
.reduce()
) to process the entire dataset, the expression itself iterates. If the node has the default per-item execution mode, it runs once per input item, but each run does the full
.all()
aggregation: wasted work, and possibly wrong.
Set the node to execute once when:
  • The expression uses
    .all().map()
    /
    .all().filter()
    /
    .all().reduce()
    .
  • Output should be a single aggregated result, not per-item.
This is
executeOnce: true
on the node. Most nodes have it.
ts
const aggregateNode = node({
    type: 'n8n-nodes-base.set',
    config: {
        executeOnce: true,            // important when using .all() in expressions
        parameters: {
            assignments: {
                assignments: [
                    {
                        name: 'totalEligible',
                        value: `={{
                            $('Get Entries').all()
                                .map(item => item.json)
                                .filter(entry => entry.eligible)
                                .length
                        }}`,
                        type: 'number',
                    },
                ],
            },
        },
    },
})
Forgetting
executeOnce
often still works but does N times the work for N items. Worse, if downstream expects one item, you get N.
Counter-case:
.all()
as a per-item lookup, NOT aggregation.
When the
.all()
reads a different node and gets filtered by the current item's identity, you want per-item execution. Each iteration produces a different result, so it's real work, not wasted.
ts
// Workflow: Get Tags (200 items) → Search Posts (10 items) → this Set Fields node.
// Each post carries a `tag_ids` array. Set Fields runs per-item (10 times)
// and resolves each post's tag_ids into the full tag objects.
tags: ={{
  $('Get Tags').all()
    .filter(tag => $('Search Posts').item.json.tag_ids.includes(tag.json.id))
}}
Setting
executeOnce: true
here would collapse the 10 outputs to 1.
The shape distinguishing the two:
  • $source.all()
    alone (aggregating across the dataset) →
    executeOnce: true
    .
  • $source.all().filter(... matches $other.item.json.x)
    (looking up by the current item) → leave
    executeOnce
    off.
Quick test: does the expression use
.all()
without combining it with another node's
.item
? If yes, the node should probably be
executeOnce: true
.
For the broader picture on iteration and explicit looping, see the
n8n-loops-official
skill.
当使用
$('Source Node').all().map(...)
(或
.filter()
.reduce()
)处理整个数据集时,表达式自身会进行迭代。如果节点使用默认的逐项目执行模式,它会为每个输入项目运行一次,但每次运行都会执行完整的
.all()
聚合:既浪费资源,结果可能还不正确。
当出现以下情况时,将节点设置为执行一次:
  • 表达式使用
    .all().map()
    /
    .all().filter()
    /
    .all().reduce()
  • 输出应为单个聚合结果,而非逐项目结果。
这对应节点上的
executeOnce: true
配置。大多数节点都支持此配置。
ts
const aggregateNode = node({
    type: 'n8n-nodes-base.set',
    config: {
        executeOnce: true,            // 使用.all()时此配置很重要
        parameters: {
            assignments: {
                assignments: [
                    {
                        name: 'totalEligible',
                        value: `={{
                            $('Get Entries').all()
                                .map(item => item.json)
                                .filter(entry => entry.eligible)
                                .length
                        }}`,
                        type: 'number',
                    },
                ],
            },
        },
    },
})
忘记设置
executeOnce
通常仍能运行,但会为N个项目做N次重复工作。更糟的是,如果下游期望一个项目,会得到N个项目。
反例:
.all()
作为逐项目查找,而非聚合。
.all()
读取另一个节点并根据当前项目的标识进行过滤时,需要逐项目执行。每次迭代都会产生不同的结果,因此是必要的工作,而非浪费。
ts
// 工作流:Get Tags(200个项目)→ Search Posts(10个项目)→ 此Set Fields节点。
// 每个帖子包含`tag_ids`数组。Set Fields节点逐项目运行(10次)
// 并将每个帖子的tag_ids解析为完整的标签对象。
tags: ={{
  $('Get Tags').all()
    .filter(tag => $('Search Posts').item.json.tag_ids.includes(tag.json.id))
}}
在此处设置
executeOnce: true
会将10个输出合并为1个。
区分两种场景的要点:
  • 仅使用
    $source.all()
    (聚合整个数据集)→ 设置
    executeOnce: true
  • 使用
    $source.all().filter(... matches $other.item.json.x)
    (根据当前项目查找)→ 不设置
    executeOnce
快速测试: 表达式是否在未结合另一个节点的
.item
的情况下使用
.all()
?如果是,节点应设置为
executeOnce: true
关于迭代和显式循环的更详细内容,请参阅
n8n-loops-official
技能文档。

Conditionals

条件判断

ts
{{ $json.status === 'active' ? 'Active' : 'Inactive' }}
{{ $json.amount >= 100 ? 'Large' : ($json.amount >= 10 ? 'Medium' : 'Small') }}
ts
{{ $json.status === 'active' ? 'Active' : 'Inactive' }}
{{ $json.amount >= 100 ? 'Large' : ($json.amount >= 10 ? 'Medium' : 'Small') }}

Date math (Luxon)

日期运算(Luxon)

ts
{{ DateTime.now().toISO() }}
{{ DateTime.fromISO($json.created_at).toFormat('yyyy-MM-dd') }}
{{ DateTime.now().minus({ days: 7 }).startOf('day').toISO() }}
{{ DateTime.fromISO($json.due).diffNow('days').days }}    // days from now (negative if past)
ts
{{ DateTime.now().toISO() }}
{{ DateTime.fromISO($json.created_at).toFormat('yyyy-MM-dd') }}
{{ DateTime.now().minus({ days: 7 }).startOf('day').toISO() }}
{{ DateTime.fromISO($json.due).diffNow('days').days }}    // 距离现在的天数(过去为负数)

Cross-node references (preferred over
$json
)

跨节点引用(优先于
$json

ts
{{ $('Webhook Trigger').item.json.body.customer_id }}
{{ $('Lookup customer').item.json.email }}
{{ $('Combine Inputs').item.json.coupon_code }}    // NoOp convergence point
.item
and
.first()
are mostly equivalent for single-item nodes, so pick one.
.first()
is more explicit,
.item
is shorter.
ts
{{ $('Webhook Trigger').item.json.body.customer_id }}
{{ $('Lookup customer').item.json.email }}
{{ $('Combine Inputs').item.json.coupon_code }}    // NoOp汇合点
对于单项目节点,
.item
.first()
基本等效,任选其一即可。
.first()
更明确,
.item
更简洁。

Multi-line logic with an IIFE arrow function

使用立即执行箭头函数实现多行逻辑

When logic is too gnarly for one line but operates on a single item, wrap it in an immediately-invoked arrow function:
ts
{{ (() => {
    // Compute total including tax
    const items = $json.line_items
    const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0)
    const tax = subtotal * 0.08
    return (subtotal + tax).toFixed(2)
})() }}
Inside, you get the full expression scope (
$json
,
$('Node Name')
,
$now
, Luxon) plus the JS you'd write in any function:
const
/
let
,
if
/
switch
,
try
/
catch
, regex.
Arguments don't work. Expressions have no caller to pass them, so
(text) => text.replace(...)
has nothing to invoke it with. Reference values from the outer scope directly. The function still needs the IIFE wrapping (
(...)()
) to actually execute.
ts
{{ (() => $json.text.replace(/\b(?:foo|bar)\b/gi, 'baz'))() }}
The outer
(
and trailing
)()
are mandatory: the first pair brackets the function expression, the trailing
()
invokes it. Drop either and n8n errors and refuses to run the workflow.
Why this over a Code node? The Code node runs in a sandboxed VM: roughly 500-1000ms worst case. The expression IIFE runs in the same context as the surrounding expression: 1-10ms consistently. For pure single-item shaping, that's a 100x gap with no functional difference. This is a common poweruser method.
A Code node still earns its place for multi-item aggregation (
$input.all()
), external libraries, or async work. See
n8n-code-nodes-official
for the decision tree, and
n8n-code-nodes-official
ARROW_FUNCTIONS_IN_EDIT_FIELDS.md
for longer examples and formatting rules.
当逻辑过于复杂无法在一行内完成,但仅操作单个项目时,可将其包裹在立即执行的箭头函数中:
ts
{{ (() => {
    // 计算包含税费的总价
    const items = $json.line_items
    const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0)
    const tax = subtotal * 0.08
    return (subtotal + tax).toFixed(2)
})() }}
函数内部可访问完整的表达式作用域(
$json
$('Node Name')
$now
、Luxon),以及任何函数中可编写的JS代码:
const
/
let
if
/
switch
try
/
catch
、正则表达式。
参数无法使用。 表达式没有调用者传递参数,因此
(text) => text.replace(...)
无法被调用。直接从外部作用域引用值即可。函数仍需用立即执行函数包裹(
(...)()
)才能执行。
ts
{{ (() => $json.text.replace(/\b(?:foo|bar)\b/gi, 'baz'))() }}
外层的
(
和末尾的
)()
是必需的:第一对括号包裹函数表达式,末尾的
()
调用函数。缺少任何一个都会导致n8n报错并拒绝运行工作流。
为何不使用Code节点? Code节点在沙箱VM中运行:最坏情况耗时500-1000ms。表达式立即执行函数在与周围表达式相同的上下文中运行:耗时稳定在1-10ms。对于纯单项目塑形,性能差距达100倍,且功能无差异。这是高级用户常用的方法。
Code节点仍适用于多项目聚合(
$input.all()
)、外部库或异步工作。详见
n8n-code-nodes-official
的决策树,以及
n8n-code-nodes-official
ARROW_FUNCTIONS_IN_EDIT_FIELDS.md
获取更长示例和排版规则。

Native JS available

可用的原生JS功能

String
,
Array
,
Number
,
Object
,
Map
,
Set
,
JSON.parse
,
JSON.stringify
,
Math
, regular expressions,
Date
(but only use Luxon).
String
Array
Number
Object
Map
Set
JSON.parse
JSON.stringify
Math
、正则表达式、
Date
(但仅推荐使用Luxon)。

Useful idioms

实用技巧

Default value when a field might be missing

字段可能缺失时设置默认值

ts
{{ $json.id || "fallback-id-here" }}
Or with optional chaining:
ts
{{ $json.user?.profile?.id ?? "anonymous" }}
Especially useful for filter values feeding queries: pass a default that matches no rows rather than letting the query fail with
undefined
.
ts
{{ $json.id || "fallback-id-here" }}
或使用可选链:
ts
{{ $json.user?.profile?.id ?? "anonymous" }}
在为查询提供过滤值时尤其有用:传递一个匹配不到任何行的默认值,而非让查询因
undefined
失败。

Embedding JSON in a text field: which serializer

在文本字段中嵌入JSON:选择哪种序列化方式

Two serializers, two contexts:
  • .toJsonString()
    for compact JSON where formatting doesn't matter. Canonical case: AI prompts. Smaller, easier on tokens, easier to scan in a prompt template.
    ts
    {{ $('Get Data').item.json.toJsonString() }}
  • JSON.stringify(value, null, 2)
    for pretty-printed JSON where formatting matters. Canonical case: email bodies, Slack messages, debug output, anywhere a human reads the result.
    ts
    {{ JSON.stringify($('Source Node').item.json, null, 2) }}
Pick deliberately. Pretty-printing inside an LLM prompt wastes tokens and clutters the model's context. Compact JSON in an email is unreadable.
两种序列化方式,适用于不同场景:
  • .toJsonString()
    用于格式无关的紧凑JSON。典型场景:AI提示词。体积更小,更节省token,在提示词模板中更易扫描。
    ts
    {{ $('Get Data').item.json.toJsonString() }}
  • JSON.stringify(value, null, 2)
    用于格式重要的格式化JSON。典型场景:邮件正文、Slack消息、调试输出,任何需要人类读取结果的场景。
    ts
    {{ JSON.stringify($('Source Node').item.json, null, 2) }}
需谨慎选择。在LLM提示词中使用格式化JSON会浪费token并干扰模型的上下文。在邮件中使用紧凑JSON则难以阅读。

JSON.stringify
and
JSON.parse
: where they belong

JSON.stringify
JSON.parse
的适用场景

JSON.stringify
and
JSON.parse
are common in expressions. Both are fine. The key discipline: stringify and parse are storage-layer operations, not interface-layer operations.
  • Stringify when you're writing into a storage column that doesn't natively hold the type. The canonical case: a Data Tables
    _object
    -postfixed string column holding what's actually an array or object. See
    n8n-data-tables-official
    .
  • Parse when you're reading back out of that storage column. Inside the workflow that owns the storage.
  • Don't propagate the stringified shape across boundaries. Sub-workflow returns, webhook responses, agent tool results, downstream consumers: all of those should receive the natural shape (arrays as arrays, objects as objects), not a stringified shell that the caller has to remember to
    JSON.parse
    .
The classic slip: a sub-workflow has a "fresh" path (data just produced by an LLM, already an array) and a "cached" path (data just read from a
_object
column, still a string). The wrong instinct is to stringify the fresh path "to match" the cached one. The right instinct is to parse the cached path so both branches produce the same natural shape on the way out.
Storage representation belongs inside the workflow that owns the storage. Outside that boundary, talk in natural shapes.
n8n-subworkflows-official
SKILL.md "Return natural shapes, not storage shapes" covers this from the sub-workflow side, and
n8n-data-tables-official
covers it from the storage side.
JSON.stringify
JSON.parse
在表达式中很常见,两者都是可行的。关键原则:序列化和反序列化是存储层操作,而非接口层操作。
  • 当写入不原生支持该类型的存储列时使用序列化。 典型场景:Data Tables中后缀为
    _object
    的字符串列存储数组或对象。详见
    n8n-data-tables-official
  • 从该存储列读取数据时使用反序列化。 在拥有该存储的工作流内部进行。
  • 不要跨边界传播序列化后的结构。 子工作流返回值、Webhook响应、助手工具结果、下游消费者:所有这些都应接收自然结构(数组为数组,对象为对象),而非需要调用者记得
    JSON.parse
    的序列化字符串。
常见错误:子工作流有“新鲜”路径(LLM生成的数据,已是数组)和“缓存”路径(从
_object
列读取的数据,仍是字符串)。错误的做法是序列化新鲜路径以“匹配”缓存路径。正确的做法是反序列化缓存路径,使两个分支在输出时产生相同的自然结构。
存储表示方式属于拥有该存储的工作流内部。在该边界之外,应使用自然结构进行交互。
n8n-subworkflows-official
的SKILL.md中“返回自然结构,而非存储结构”从子工作流角度覆盖了此内容,
n8n-data-tables-official
则从存储角度覆盖了此内容。

Returning the right type: when to wrap in
={{ ... }}

返回正确类型:何时包裹在
={{ ... }}

Some node fields will treat your value as a string literal unless you tell n8n to evaluate it as an expression. Wrapping in
={{ ... }}
(the
=
prefix turns the field into expression mode) returns the actual type the inner code produces:
ts
// String literal (default behavior)
foo: 'plain string'

// Number
foo: '={{ 100 }}'

// Boolean
foo: '={{ true }}'

// Object (the `={{ ... }}` is what makes the receiver see an object, not a string)
foo: '={{ { "valid": true, "items": [] } }}'

// Array
foo: '={{ ["a", "b", "c"] }}'

// Reference to another node's value (preserves whatever type that value already is)
foo: '={{ $("Source Node").item.json.payload }}'
When the type matters: object/array fields on Set / Edit Fields (with the column's
Type
set to Object or Array), JSON body parameters on HTTP Request, structured inputs to a sub-workflow's typed
workflowInputs.values[type]
, agent tool parameters, anywhere the receiving node validates the type. Without the
={{ ... }}
wrapper, you'd be passing a string and the receiver either coerces or errors.
Reference by node name, not
$json
, per non-negotiable #1 above:
ts
// WRONG
foo: '={{ $json.payload }}'

// RIGHT
foo: '={{ $("Source Node").item.json.payload }}'
The exception: if
$json
is genuinely the right thing (no intermediate transforms, no convergence) and the field is a per-item slot on a node that's directly downstream of one source. Even then, named references are more refactor-safe.
某些节点字段会将值视为字符串字面量,除非告知n8n将其作为表达式求值。包裹在
={{ ... }}
中(
=
前缀将字段切换为表达式模式)会返回内部代码生成的实际类型:
ts
// 字符串字面量(默认行为)
foo: 'plain string'

// 数字
foo: '={{ 100 }}'

// 布尔值
foo: '={{ true }}'

// 对象(`={{ ... }}`使接收方看到对象,而非字符串)
foo: '={{ { "valid": true, "items": [] } }}'

// 数组
foo: '={{ ["a", "b", "c"] }}'

// 引用另一个节点的值(保留该值的原有类型)
foo: '={{ $("Source Node").item.json.payload }}'
当类型很重要时:Set/Edit Fields节点上的对象/数组字段(列的
Type
设置为Object或Array)、HTTP Request节点的JSON体参数、子工作流的类型化
workflowInputs.values[type]
的结构化输入、助手工具参数,任何接收节点会验证类型的场景。如果没有
={{ ... }}
包裹,传递的将是字符串,接收方要么强制转换要么报错。
根据必须遵守的规则#1,通过节点名称引用,而非
$json
ts
// 错误
foo: '={{ $json.payload }}'

// 正确
foo: '={{ $("Source Node").item.json.payload }}'
例外情况:如果
$json
确实是合适的选择(无中间转换、无分支汇合),且字段是直接位于单一数据源下游节点的逐项目插槽。即便如此,通过名称引用也更便于重构。

Multi-line expression with explanatory comment

带解释性注释的多行表达式

ts
{{
  // Default to avoid query errors when user_id is missing.
  // The fallback UUID is a known-empty row.
  $json.id || "305f7106-6988-4651-b26a-18979641b7b5"
}}
Encouraged when logic is non-obvious. The comment will be there for the next reader.
ts
{{
  // 用户ID缺失时使用默认值避免查询错误。
  // 备用UUID对应已知的空行。
  $json.id || "305f7106-6988-4651-b26a-18979641b7b5"
}}
当逻辑非直观时,推荐使用注释。注释会为后续读者保留。

What expressions CAN'T do

表达式无法实现的功能

  • Use external libraries (no
    require
    ).
  • Async / await.
$json
itself is the current item only, but expressions can reach across items via
$input.all()
,
$input.all()[3]
,
$('Source Node').all()
, etc. See "Method chains" above.
For those, see
n8n-code-nodes-official
.
  • 使用外部库(不支持
    require
    )。
  • 异步/await。
$json
本身仅代表当前项目,但表达式通过
$input.all()
$input.all()[3]
$('Source Node').all()
等方式跨项目访问数据。详见上文“方法链”部分。
如需实现上述功能,请参阅
n8n-code-nodes-official

Decision: expression, Edit Fields, or Code node?

决策:使用表达式、Edit Fields还是Code节点?

Per
n8n-code-nodes-official
's decision tree:
1. Single-field transform → expression in the field
2. Multi-step pure logic on one item → arrow function in Edit Fields
3. Multi-source aggregation, libraries, or stateful → Code node
Expression is the default. Reach past it only when input or scope demands it.
根据
n8n-code-nodes-official
的决策树:
1. 单字段转换 → 在字段中使用表达式
2. 单个项目的多步骤纯逻辑 → 在Edit Fields中使用箭头函数
3. 多源聚合、外部库或有状态操作 → 使用Code节点
表达式是默认选择。仅当输入或作用域要求时,才考虑其他方式。

The "extra node" smell

“额外节点”异味

Common reaches-for-extra-nodes that should stay in expressions:
Adding this nodeBetter as
DateTime node to format a date
DateTime.fromISO(...).toFormat(...)
in the consumer's expression
Set node to build an email bodyInline the expression in the email node's body field
Set node to compute a derived field used onceInline at the consumer
Two nodes (Set + IF) to compute then testOne IF with the computation in its condition expression
Code node to call
.toUpperCase()
Just the expression
Adding nodes for transforms means more visual clutter, slower workflows, harder reading.
When extra nodes ARE right:
  • The transform is reused across multiple downstream consumers.
  • The transform is heavy (Code node territory).
  • The transform is the primary purpose of a section (a clear "compute X" step).
常见的应使用表达式而非额外节点的场景:
添加此节点更好的做法
使用DateTime节点格式化日期在消费节点的表达式中使用
DateTime.fromISO(...).toFormat(...)
使用Set节点构建邮件正文在邮件节点的正文字段中内联表达式
使用Set节点计算仅使用一次的派生字段在消费节点中内联表达式
使用两个节点(Set + IF)计算并测试使用一个IF节点,在条件表达式中完成计算
使用Code节点调用
.toUpperCase()
直接使用表达式
为转换操作添加节点会增加视觉混乱、降低工作流速度、更难阅读。
以下场景适合使用额外节点:
  • 转换逻辑需复用给多个下游消费者。
  • 转换逻辑复杂(属于Code节点的适用场景)。
  • 转换是某段流程的核心目的(明确的“计算X”步骤)。

Anti-patterns

反模式

Anti-patternWhat goes wrongFix
Set node that exists to extract one field from a webhook body for one downstream consumerExtra node for what should be inlined, fragile to refactorDelete the Set node, reference
$('Webhook').item.json.body.x
directly in the consumer
Multiple consecutive Set nodes each defining one fieldWorkflow paddingCollapse. Most aren't needed, and for the ones that are, group into one Set node
Using
$json.x
deep in a workflow with multiple branches and intermediate transforms
Reference breaks when an intermediate is added or context is clearedUse
$('Source Node').item.json.x
. Add a NoOp convergence point if branches merge.
Adding a DateTime node to format a timestampExtra node for what's a 1-line Luxon expression
{{ DateTime.fromISO($('Source').item.json.x).toFormat('yyyy-MM-dd') }}
Set node to build email HTML, then read it in the Email nodeTwo nodes for what's one expressionBuild the HTML directly in the email node's body field
new Date($json.created_at)
instead of Luxon
Loses formatting/manipulation features
DateTime.fromISO($('Source').item.json.created_at)
One-line expression that's actually 200 charsUnreadableMulti-line with arrow function, indented, with comments
$json.foo.bar.baz
without checking
$json.foo
exists
Crashes on missing intermediateUse
?.
chain:
$('Source').item.json.foo?.bar?.baz
Hardcoding values in expressions that should be configMagic stringsUse
$vars.X
(n8n Variables, paid plans) or a Data Table
Branches converge with
$json
references downstream
Whichever branch fired last wins, non-deterministicInsert a NoOp ("Combine Inputs") at the merge, reference by name
Using
$env.X
in any expression
Doesn't work; throws at runtimeFor config use
$vars.X
(paid plans) or a Data Table. For secrets use the credential system
反模式问题所在修复方案
Set节点仅用于从Webhook体提取一个字段供单个下游消费者使用本应内联的操作却使用了额外节点,重构时易失效删除Set节点,在消费节点中直接引用
$('Webhook').item.json.body.x
多个连续的Set节点各定义一个字段工作流冗余合并节点。大多数节点无需存在,必要的字段可集中到一个Set节点中
在包含多个分支和中间转换的工作流深处使用
$json.x
添加中间节点或清除上下文时引用会失效使用
$('Source Node').item.json.x
。如果分支汇合,添加NoOp汇合点
使用DateTime节点格式化时间戳本可通过一行Luxon表达式完成却使用了额外节点使用
{{ DateTime.fromISO($('Source').item.json.x).toFormat('yyyy-MM-dd') }}
使用Set节点构建邮件HTML,然后在Email节点中读取本可通过一个表达式完成却使用了两个节点在邮件节点的正文字段中直接构建HTML
使用
new Date($json.created_at)
而非Luxon
丢失格式化/操作功能使用
DateTime.fromISO($('Source').item.json.created_at)
实际长度200字符的单行表达式难以阅读使用多行箭头函数,缩进并添加注释
未检查
$json.foo
是否存在就使用
$json.foo.bar.baz
中间字段缺失时会崩溃使用
?.
链:
$('Source').item.json.foo?.bar?.baz
在表达式中硬编码应配置的值魔法字符串使用
$vars.X
(n8n变量,付费计划)或Data Table
分支汇合后下游使用
$json
引用
最后触发的分支获胜,结果不确定在汇合处插入NoOp节点(如“Combine Inputs”),通过名称引用
在任何表达式中使用
$env.X
无法工作;运行时会报错配置项使用
$vars.X
(付费计划)或Data Table。密钥使用凭证系统