n8n-expressions

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
    .
  • 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
  • 需要通过设置
    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节点,尤其是推导逻辑复杂时。
多个连续的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
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
技能文档。

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

使用IIFE箭头函数实现多行逻辑

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
for the decision tree, and
n8n-code-nodes
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(...)
无法被调用。直接从外部作用域引用值即可。函数仍需IIFE包装(
(...)()
)才能执行。
ts
{{ (() => $json.text.replace(/\b(?:foo|bar)\b/gi, 'baz'))() }}
外层的
(
和末尾的
)()
是必需的:第一对括号包裹函数表达式,末尾的
()
调用函数。缺少任何一个,n8n都会报错并拒绝运行工作流。
为何不使用Code节点? Code节点在沙箱VM中运行:最坏情况下耗时约500-1000ms。表达式IIFE在与周围表达式相同的上下文中运行:耗时稳定在1-10ms。对于纯单条目塑形,性能差距可达100倍,且功能无差异。这是高级用户常用的方法。
当需要处理多条目聚合(
$input.all()
)、使用外部库或异步操作时,仍需使用Code节点。详见
n8n-code-nodes
的决策树,以及
n8n-code-nodes
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提示词中使用美观格式会浪费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
    .
  • 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
SKILL.md "Return natural shapes, not storage shapes" covers this from the sub-workflow side, and
n8n-data-tables
covers it from the storage side.
JSON.stringify
JSON.parse
在表达式中很常见,两者都可正常使用。关键原则:序列化和反序列化是存储层操作,而非接口层操作。
  • 当写入不原生支持该类型的存储列时,使用序列化。 典型场景:Data Tables中后缀为
    _object
    的字符串列,实际存储的是数组或对象。详见
    n8n-data-tables
  • 从该存储列读取数据时,使用反序列化。 在拥有该存储的工作流内部进行。
  • 不要将序列化后的结构传播到边界之外。 子工作流返回值、Webhook响应、助手工具结果、下游消费者:所有这些都应接收原生结构(数组为数组,对象为对象),而非需要调用者记得
    JSON.parse
    的序列化字符串。
常见错误:子工作流有“新鲜”路径(由LLM生成的数据,已是数组)和“缓存”路径(从
_object
列读取的数据,仍是字符串)。错误的做法是将新鲜路径的数据序列化以匹配缓存路径。正确的做法是反序列化缓存路径的数据,使两个分支在输出时都产生原生结构。
存储表示应仅存在于拥有该存储的工作流内部。在该边界之外,使用原生结构进行交互。
n8n-subworkflows
的SKILL.md中“返回原生结构,而非存储结构”从子工作流视角覆盖了此内容,
n8n-data-tables
则从存储视角覆盖了此内容。

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
{{
  // 当user_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
.
  • 使用外部库(不支持
    require
    )。
  • 异步/await操作。
$json
本身仅指向当前条目,但表达式可通过
$input.all()
$input.all()[3]
$('Source Node').all()
等方式跨条目访问数据。详见上文的「方法链」部分。
如需实现上述功能,请参阅
n8n-code-nodes

Decision: expression, Edit Fields, or Code node?

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

Per
n8n-code-nodes
'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
的决策树:
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.bar.baz
但未检查
$json.foo
是否存在
中间字段缺失时会崩溃使用
?.
链:
$('Source').item.json.foo?.bar?.baz
在表达式中硬编码应作为配置的值魔法字符串使用
$vars.X
(n8n变量,付费计划)或Data Table
分支汇合后下游使用
$json
引用
结果取决于最后触发的分支,不具有确定性在汇合处插入NoOp节点(如"Combine Inputs"),通过名称引用
在任何表达式中使用
$env.X
无法工作,运行时会报错配置使用
$vars.X
(付费计划)或Data Table。密钥使用凭证系统