DEV Community

SolutionsCraft
SolutionsCraft

Posted on Originally published at solutionscraft.com on

n8n's Expression Engine Just Got Stricter

You upgrade n8n, nothing in the changelog looks scary, and a week later a workflow that's run quietly for months starts throwing errors on an expression you haven't touched. Nobody edited it. The data feeding it didn't change shape. The only thing that moved is n8n itself, and buried in the 2.35.0 release notes is the line that explains it: n8n made its VM expression engine the default, replacing the older Tournament engine that had run every {{ }} expression since the beginning.

This one's worth taking seriously for a reason the Notion v3 migration wasn't: node versions are pinned per-workflow, so upgrading n8n doesn't silently change how an existing Notion node behaves. The expression engine isn't like that. It's set with an environment variable at the instance level, and it applies to every workflow's next execution the moment you're running 2.35.0 or later, whether or not you asked for it.

Before you start

You'll need shell or dashboard access to your n8n instance (to check its version and, if needed, set an environment variable) and edit access to the workflows you want to audit. This applies to self-hosted n8n; if you're on n8n Cloud, more on that at the end.

Step 1: Confirm you're actually on the new engine

Check your instance version first (Settings → About in the UI, or n8n --version from the CLI). Anything 2.35.0 or later is running the VM engine by default unless someone already set N8N_EXPRESSION_ENGINE=legacy. If you're below 2.35.0, this doesn't apply yet, but it will the moment you upgrade, so it's worth reading before you do rather than after something breaks.

Step 2: Understand what actually changed

Tournament and the new VM engine both run your expression as JavaScript, so the language itself hasn't changed. What changed is how errors during evaluation get handled. Tournament wraps every expression in a try/catch and, depending on the error type, would often swallow a failure quietly and resolve to undefined rather than stop the workflow. The VM engine runs expressions inside an actual isolated V8 context (via isolated-vm) with a hard memory limit (128MB by default) and execution timeout (5 seconds by default), and it's meaningfully stricter about which errors it lets pass silently versus which ones it re-throws.

Blip: A workflow that silently resolves to undefined and limps forward is a workflow with a bug you haven't found yet. I'd rather it just told me.

Blip's not wrong, but "stricter" still means workflows built around the old quiet-failure behavior are the ones at risk now. The pattern to look for is any expression that chains into a property that might not exist on every item, something that used to quietly become undefined and now has a real chance of throwing instead.

Step 3: Find the risky pattern in your workflows

The expressions most likely to behave differently are direct property chains with no guard, especially several levels deep or reaching into data that isn't guaranteed present on every item:

{{ $json.customer.address.city }}
Enter fullscreen mode Exit fullscreen mode

If customer or address is ever missing on an item (a common shape for optional fields from an API, a webhook payload that varies by event type, or an upstream node that doesn't always return the same structure), this is exactly the kind of expression that could tolerate the gap quietly before and won't now. I confirmed the underlying JavaScript behavior directly, since both engines evaluate real JS under the hood:

const data = { customer: {} };
data.customer.address.city;
// Cannot read properties of undefined (reading 'city')

data.customer.address?.city;
// undefined, no throw

data.customer.address?.city ?? "Unknown";
// "Unknown"
Enter fullscreen mode Exit fullscreen mode

Search your workflows for expressions with two or more chained . property accesses and no ?. anywhere in the chain. That's your audit list. Pay closest attention to workflows using an Error Trigger, or ones with sub-nodes that reference data from earlier in the flow across an AI Agent connection; paired-item resolution across those boundaries is one of the specific spots the n8n team had to patch for consistency between the two engines, which tells you it's a genuine edge case, not a hypothetical one.

Opening every workflow by hand to eyeball its expressions doesn't scale past a handful. If you're self-hosted and have CLI access, n8n's export:workflow command dumps every workflow to JSON in one shot, and from there it's a normal grep problem:

n8n export:workflow --all --output=./audit/
grep -roE '\{\{[^}]*\.[a-zA-Z_]+\.[a-zA-Z_]+[^}]*\}\}' ./audit/ | grep -v '?\.'
Enter fullscreen mode Exit fullscreen mode

I tested this exact pattern against a sample workflow JSON with one guarded and one unguarded expression, and it correctly pulled only the unguarded one. It's a starting list, not a verdict (some flagged expressions will turn out fine once you check whether that particular field can actually be missing), but it turns "audit the instance" into "review a short list" instead of "click through every workflow."

Step 4: Fix what you find

For each risky expression, add optional chaining and a sensible fallback:

{{ $json.customer.address?.city ?? "Unknown" }}
Enter fullscreen mode Exit fullscreen mode

This is a one-character-per-link change (?. instead of .) plus a ?? default at the end, and it makes the expression behave the same way under either engine: no silent undefined, no thrown error, just the fallback value you chose. If an expression is doing something more involved than a property chain (a multi-step transform, several fallbacks chained together), it's usually clearer, and easier to test, moved into a Code node instead, where you can wrap it in an actual try/catch and decide exactly what happens on failure.

Step 5: Use the rollback as a stopgap, not a fix

If you've just upgraded and don't have time to audit before your workflows run again, you can buy yourself room with an environment variable:

N8N_EXPRESSION_ENGINE=legacy
Enter fullscreen mode Exit fullscreen mode

This restores Tournament's behavior instance-wide, immediately. Treat it as a pause button, not a destination. The VM engine is where n8n is investing going forward (it's also faster on repeated expressions thanks to code caching, and the isolation is a real security improvement), so the goal is auditing your way back to the default, not living on legacy indefinitely.

Tip: Set the variable, restart, confirm your previously-broken workflow runs clean again, then work through Step 3's audit list on your own schedule instead of your production traffic's.

Where to start

If you've already upgraded and something's actively failing, set N8N_EXPRESSION_ENGINE=legacy first to stop the bleeding, then work through Step 3's search for unguarded property chains before removing the rollback. If you haven't upgraded yet, do the audit now, while it's a code review instead of an incident. Either way, the fix is the same one-line change repeated as many times as your audit turns up. It's the same audit-before-it-bites-you shape as checking your instance for leaked n8n API tokens, just for an expression engine instead of a credential.

If you're on n8n Cloud, this engine switch already happened for you as part of a coordinated rollout n8n tested ahead of time. You don't control when it lands, but you also don't have to schedule the audit around your own upgrade timing. If owning that timing yourself isn't something you want to keep doing release after release, a free n8n Cloud trial is worth a look.

Disclosure: the n8n Cloud link above is an affiliate link — if you sign up through it, we may earn a commission at no extra cost to you. See our affiliate disclosure for details.

Top comments (0)