n8n previews your expression with one engine and runs it with another
Since 2.35 the server evaluates expressions inside a V8 isolate while the editor still uses the old path. Anything the two engines disagree on shows a correct preview and a different value at execution, with no error anywhere. The first wave of disagreements was fixed within three weeks. Others are still open, and the documentation still says the old engine is the default.
The preview said sorted. The output said null
On 18 August 2026, four days after n8n 2.35.3 became the stable release, a self-hosted user filed this:
After upgrading n8n from 2.34.5 to 2.35.3, several JavaScript Array methods called directly on arrays from
$jsonnow evaluate tonullin an Edit Fields (Set) node.The affected methods I have reproduced are:
bash{{ $json.data.sort() }} {{ $json.data.splice(0, 2) }} {{ $json.data.fill("X", 0, 2) }} {{ $json.data.copyWithin(0, 2, 4) }}Each of these returns:
nullThis did not occur on n8n 2.34.5.
As a workaround, creating a copy of the array before calling the method works. For example:
{{ [...$json.data].sort() }}returns the expected sorted array.
- the reporter, n8n issue #36540, 18 August 2026
The same evening the reporter had found the switch:
Setting:
N8N_EXPRESSION_ENGINE=legacyon the same 2.35.3 installation makes the same expressions work again.
- same issue, 18 August 2026
Another user reproduced all four against the official Docker images for both
versions. On the forum a week later someone on 2.36.7 reported the same thing for
{{ $json.arr.sort( (a,b) => b-a ) }} - a compare function of the same form the
n8n documentation shows as a working example, arr.sort((a, b) => (a - b)) - with
the output [{"sorted": null}].
Nothing in any of these reports is an error. The expression editor shows the
sorted array. The node writes null. If the next node is a database insert, the
database gets null, the execution is green, and the person who notices is
whoever reads the table.
What changed on 7 August
One line, in one configuration file. At tag n8n@2.34.5, the expression engine
setting reads:
* `vm` is currently **experimental**. Use at your own risk.
*/
@Env('N8N_EXPRESSION_ENGINE', expressionEngineSchema)
engine: 'legacy' | 'vm' = 'legacy';At tag n8n@2.35.0 the same lines read:
* - `vm` (default) runs expressions in a V8 isolate.
* - `legacy` runs expressions without isolation. Less secure and soon to be deprecated.
*/
@Env('N8N_EXPRESSION_ENGINE', expressionEngineSchema)
engine: 'legacy' | 'vm' = 'vm';The pull request that made the change was opened on 6 August by an n8n core
platform engineer and merged the next morning. Its summary: "Makes the vm
expression engine the default and no longer experimental. N8N_EXPRESSION_ENGINE
now defaults to vm; legacy remains available as an opt-out and is on its way
to deprecation." It shipped in the 2.35.0 beta on 11 August and in 2.35.3, the
first stable release of that line, on 14 August.
It did not stay on 2.x. A port to the 1.x branch was merged on 18 August and released as 1.123.73 the next day - so an instance held back on 1.x for stability got the new engine as a patch release, five days after the 2.x stable did.
Why the preview cannot tell you
The important part is not that the engine changed. It is that only half of it did.
The new engine runs expressions in an isolated V8 context on the server. The editor runs in your browser, where that isolate does not exist, and the code says so - this is the comment in the expression renderer on the current main branch:
The VM engines (isolated-vm, quickjs) are Node-only; the browser always uses the legacy path below.
packages/workflow/src/expression.ts, n8n source, read 12 September 2026
So the value you see while editing a node is computed by the legacy engine, and the value the workflow uses is computed by the new one. That is not a bug that a later version fixes; it is the architecture. Every difference in behaviour between the two engines, present and future, shows up the same way: a preview that looks right and an execution that differs from it.
Why null rather than an error is also readable from the source. In the new
engine as shipped in 2.35, workflow data reaches an expression as read-only
proxies - the code comments at that tag call them "read-only views of host
data". Calling sort() on one tries
to write back into it, the write is refused, and the resulting TypeError reaches
an error handler that the isolate injects around every expression. That handler's
own comment explains its policy:
This handler must match the legacy engine's behavior (set in expression.ts via setErrorHandler):
Re-throw ExpressionError / ExpressionExtensionError
Swallow everything else (TypeErrors, generic Errors, etc.)
packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts, n8n source at tag 2.35.0
"Match the legacy engine's behavior." The new engine did not invent the swallowed error; it copied the old engine's contract on purpose. What changed is which operations now produce a swallowed error - and the preview, running the engine that does not produce one, has nothing to show.
Who was told
The release notes on docs.n8n.io for 2.35, headed "Enhanced HITL approval for
Slack and Telegram, plus 15 other features", list sixteen items. None of them
mentions expressions, the engine, or N8N_EXPRESSION_ENGINE; nor does any later
block on that page up to 2.38.1, the newest it covered on 12 September.
The change is recorded in one line of one auto-generated release body, and
nowhere human-written: the GitHub release for n8n@2.35.0, where it is one item
among 105 - "core: Make the vm expression engine the default" - between an
Azure Key Vault option and an MCP SDK migration. The 1.x release five days later
has the equivalent line.
The documentation page for the setting says the opposite. As read on 12 September:
Experimental
The
vmengine is experimental. n8n runs thelegacyengine by default.
- Expression engine environment variables, n8n documentation, read 12 September 2026
and, in the table below it, Default: legacy with the note "vm is experimental;
legacy remains the default". That page was written on 19 June by the same
engineer who later flipped the default. The pull request that flipped it listed
"the operator-facing env var docs in n8n-docs (companion PR needed)" as
deliberately out of scope, and its "Docs updated or follow-up ticket created"
checkbox was left unticked. On 12 September, 36 days after the merge, the
documentation repository had no commit and no open pull request touching that
page. If it is still unchanged as you read this, the gap is past two months.
One notification did ship with the change: a deprecation warning, logged at
startup when N8N_EXPRESSION_ENGINE=legacy is set, saying the legacy engine "is
no longer considered secure, and will be removed in a future version". That
warning goes to the people who had already opted out of the new engine. The people
who were switched onto it received nothing.
This is not the first time this summer that n8n changed what a running instance does and put the explanation somewhere other than the release notes. In August and September it was security fixes with empty changelogs. With this vendor the GitHub release body is the complete record and the human-written notes are a selection.
The opt-out that could quietly fail
For the first week the escape hatch had a hole in it. A second pull request from the same engineer, merged on 20 August, describes it:
Concretely, an operator who sets
N8N_EXPRESSION_ENGINE=legacy(or the_FILEvariant) to opt out of the vm expression engine could silently end up running the opposite of what they configured, with no error, only a warning in the logs.
- n8n pull request #36644, merged 20 August 2026
The causes it lists are ordinary: a trailing newline in a _FILE value (which
echo value > file produces by default), a trailing space from a Kubernetes
ConfigMap, quotes surviving Docker Compose's env_file:. Any of those failed
validation, and on failure the setting fell back to its default - which was now
vm. The fix is in 2.35.6, 2.36.4 and 1.123.75, all released on 21 August. On
2.35.3 through 2.35.5, an instance that had opted out with a slightly untidy value
was running the new engine and saying so only in a log warning.
What is fixed and what is not
The .sort() report at the top was treated as a bug. An n8n engineer opened a
fix on 26 August, closed the issue as completed on 27 August, and a second engineer
replaced the approach the next day with a broader one - the proxies now accept
writes within a single evaluation, copy-on-write, so native mutation works and
still never reaches the workflow data. The pull request's description of the
problem is the most precise summary of this whole class of failure:
The VM expression engine's lazy data proxies rejected all writes, mostly silently: index/key assignments were lost (the expression renders the assigned value, but later reads see stale data),
deleteresurrected the value on the next read, andpush/popleft the proxy with a stalelength. Wrong data that looks right.
- n8n pull request #37262, merged 28 August 2026
Both fixes are in 2.38.1, released 1 September, and the first of them was backported to 2.37.9 on 3 September. The forum reporter confirmed: "works again in 2.38.0". Fourteen days from first report to a fix in the stable release, which is quick. The rest of the list, as it stood when this was written, is less tidy.
counter++ and any expression that writes to node data - closed, will not be
fixed. A user whose {{$node["settings"].json["counter"]++}} had stopped
incrementing got this answer:
This is an expected side effect of a change in 2.35.x, not a bug. Expressions were never meant to write back to node data —
{{$node["settings"].json["counter"]++}}only ever "worked" because of an implementation detail in the old expression engine that let it mutate the underlying data by accident. That's no longer possible with the new engine, which is more locked down for security reasons.
- an n8n engineer, n8n issue #36756, 21 August 2026
The recommended replacement is getWorkflowStaticData() from a Code node, with a
caveat he also gives: static data is not saved during manual test runs in the
editor. "Expected side effect" is the vendor's position on this one report, and
only this one - the sort regression was called a bug and fixed. The two are easy
to conflate because they surface identically.
A field named toString - open. The first report of the whole episode,
filed fifteen hours before the sort one, was from someone receiving Jira webhooks,
whose changelog payloads carry a field literally called toString:
On n8n 2.35.x:
Property Result JiraToStringTrue toStringFalse On n8n 2.34.x, both evaluate to True.
- the reporter, n8n issue #36473, 18 August 2026
An IF node comparing that field silently takes the wrong branch. Bracket notation
does not help - $json['toString'] resolves to the same prototype method. The
cause is a single in check in the proxy that consults the prototype chain before
the data, and it was still on the main branch at the time of writing; a community
fix had been waiting since 30 August with a "needs info" label and no reply from
the vendor in either issue.
MongoDB _id.toString() - open. The mirror image: here the prototype method
is the thing you want, and it is gone, because only an object's own keys cross into
the isolate. {{ $('MongoDB').item.json._id.toString() }} returns
[object Object], and the reporter's words were "it doesn't throw any errors, it
simply fails to convert to a string correctly" - on 2.36.8, three days after the
sort fix was merged and a day before it shipped.
A community pull request existed and was unmerged.
.extractUrlPath() - open, and not a null. On a URL with a non-ASCII
character the editor shows /wiki/K%C3%A4se and the execution produces
/wiki/Käse, because the browser uses the platform's URL parser and the isolate
has a hand-written one. A different value, not a missing one, which is the worse
kind.
Date values inside raw JSON - fixed but unreleased. A fix merged on
9 September for {{ $now }} inside a JSON body was, at the time of writing, in no
released version, including the 2.39.4 beta.
Anything on this list may have moved by the time you read it. The release bodies on GitHub are where it will be recorded, in one line each.
On n8n Cloud there is no switch
N8N_EXPRESSION_ENGINE is an environment variable, and Cloud does not expose
environment variables. The clearest statement of that comes from a forum
moderator, not from n8n - no n8n employee has said whether the engine is on for
Cloud in any thread found:
N8N_EXPRESSION_ENGINEis a self-hosted variable with no Cloud equivalent, so help@n8n.io has to check whether vm is enabled on your instance and put it back to legacy.
- a community moderator, MCP bridge acquisition error, n8n community, 4 August 2026
Threads from July and early August show Cloud instances - one of them on 1.123.69 - throwing "No bridge acquired for this context", an error the source produces only when the isolate engine is initialised, before the default changed for self-hosted; n8n has not said whether Cloud runs it. Either way, the old engine is not available to a Cloud workspace, and the instance updates itself on the vendor's schedule.
Which of your expressions differ
The export in step 3 is the input; step 4 is the answer.
- Find your version. The Help menu at the bottom of the left sidebar has
"About n8n", and the Settings sidebar shows it at the bottom. From 2.35.3 or 1.123.73 upward, the server engine is
vmunless something says otherwise. Anything on 2.35.3 to 2.35.5 that opted out is worth a second look, for the reason in the section above. - Check the opt-out, if you set one. The deprecation warning in the startup
log appears only if the raw value is exactly
legacy, so if it is there, the instance is on the legacy engine. If it is not there, that proves nothing: a_FILEopt-out never triggers it, and a value with a stray space or quotes - accepted since 2.35.6 - runs the legacy engine without it. The reliable test is step 4. - Search your workflows for the constructs that differ. The CLI exports
everything with
n8n export:workflow --all --output=workflows.json, and the file is plain text. Look for.sort(,.splice(,.fill(,.copyWithin(,++,--,.toString(),.extractUrlPath(, and any expression that assigns into$jsonor$node[...].json. On 2.38.1 or later, and on 2.37.9 or later in the 2.37 line, the array methods are fine again; the rest are not. Add--publishedto export the published versions rather than the drafts, and run the command inside the container if n8n is in Docker. Fields in your incoming data namedtoString,valueOforhasOwnPropertywill not show up in this search, because the problem is in the payload, not the expression - if a source system can send them, an IF or Filter on that field is suspect. - Compare an execution with the preview. For each workflow that touches anything from step 3, open a recent execution, click the node, and put its output next to what the editor's expression preview shows for the same input. A difference is the finding. No difference on the runs you looked at is not an all-clear, because the mismatch depends on the data.
- Decide per expression, not per instance. Copy-first (
[...$json.data].sort()) was correct on both engines before the fix and remains correct after it. Counters belong ingetWorkflowStaticData(). Fields with prototype names need renaming upstream or a Code node. SettingN8N_EXPRESSION_ENGINE=legacybuys time on self-hosted, with a deprecation warning attached that means what it says.
Until the legacy engine goes
An expression audit on an instance with a few dozen workflows is a few hours, and it has a price if you would rather not spend them - most of the n8n work that reaches us is this shape. But the audit is not really about 2.35. The editor previews with one engine and the server runs another, and that stays true until the legacy engine is removed, at which point nothing in the roadmap says what the preview will do. Every expression written against the preview is written against an engine the server no longer runs.
The page that documents the setting was 36 days out of date when this was
written and still called legacy the default. Whether it has been corrected is
the quickest test of whether anything else here has moved: the page's history
is public, and so are the issues above, each still open on the day this was
written.
Sources
- expression-engine.config.ts - n8n source, read 12 September 2026 from the raw files at tags
n8n@2.34.5,n8n@2.35.0and on master. The default value and the comment at each tag. - n8n pull request #35738 - "Make the vm expression engine the default", opened 6 August 2026, merged 7 August 2026, read 12 September 2026 through the GitHub API. The summary quoted above, the "companion PR needed" note on documentation, and the unticked documentation checkbox. Pull request #36498 is the port to 1.x, merged 18 August 2026.
- n8n releases on GitHub - read 12 September 2026 through the GitHub API. The bodies of
n8n@2.35.0(11 August, 105 items, one about the engine),n8n@2.35.3(14 August, first stable),n8n@1.123.73(19 August),n8n@2.35.6,2.36.4and1.123.75(21 August, the environment value fix),n8n@2.38.1(1 September) andn8n@2.37.9(3 September); the latest stable on that date wasn8n@2.38.7, published 11 September. - n8n release notes - n8n documentation, read 12 September 2026. The 2.35 block with its sixteen items and the absence of any mention of the expression engine through 2.38.1.
- Expression engine environment variables - n8n documentation, read 12 September 2026, together with its history in the n8n-docs repository, where the page was created on 19 June 2026 and its text had not changed since, the only later commits being a June migration of the documentation site. The "Experimental" note and the
legacydefault in the table. - expression.ts, isolated-vm-bridge.ts and lazy-proxy.ts - n8n source, read 12 September 2026 at tag
n8n@2.35.0and on master. The browser-always-legacy comment (master), the injected error handler and its policy comment, the read-only proxy descriptors as they were at 2.35.0 (copy-on-write since 2.38.1), and theincheck that resolves prototype names ahead of data, still present on master. - n8n pull request #36644 - merged 20 August 2026, read 12 September 2026 through the GitHub API. The description of an opt-out value failing validation and falling back to the default.
- n8n issue #36540 - opened 18 August 2026, closed as completed 27 August 2026 by the engineer who fixed it, read 12 September 2026 through the GitHub API. The report, the
legacyconfirmation, the version matrix posted by a second user, and the root-cause comment. - n8n pull request #37262 - merged 28 August 2026, read 12 September 2026 through the GitHub API. The copy-on-write change, its description of silent write loss, and its removal of the copy-first shims from pull request #37172.
- n8n issue #36756 - opened 20 August 2026, closed as not planned 21 August 2026, read 12 September 2026 through the GitHub API. The counter report and the "expected side effect" reply. The replier's GitHub profile lists the n8n organisation; his association on the issue is contributor, not member.
- n8n issue #36473 and issue #37358 - opened 18 and 30 August 2026, both open on 12 September 2026, read through the GitHub API. The
toStringfield on Jira payloads, the bracket-notation test, the MongoDB_idreport of 31 August on 2.36.8, and the pending community fixes #37359 and #37422. - n8n issue #38264 - opened 9 September 2026, open on 12 September, read through the GitHub API. The
extractUrlPath()preview and execution values, reproduced by the reporter on the main branch rather than on a released version. - n8n pull request #38034 and issue #37584 - merged 9 September 2026 and opened 2 September 2026 respectively, read 12 September 2026 through the GitHub API. The date-in-JSON fix, the reporter's note that it worked on 2.33.3, and the fix's absence from every release up to the 2.39.4 beta on that date.
- Sorting in "Edit fields" node returns null - n8n community, 26 August to 1 September 2026, read 12 September 2026. The 2.36.7 report with the documentation's own compare function, and the confirmation on 2.38.0.
- MCP bridge acquisition error and Airtable node resource locator fails with "No bridge acquired for this context" - n8n community, 4 and 10 August 2026, read 12 September 2026. Cloud instances, one of them on 1.123.69, producing the isolate error before the default changed, and the moderator's statement that the variable has no Cloud equivalent.
- Use the command line - n8n documentation, read 12 September 2026. The
export:workflow --allcommand.
Broken workflow? Fix S — $300, 2 business days, fixed price.
Get my quote in 24hWritten by the Fixmation team.