N8n: Switch node silently routes false when comparing a boolean — "Wrong type: 'true ' is a string but was expecting a boolean"

Posting this because it cost me a few hours and it fails silently, which is the worst combination. If you route anything important through an IF or Switch node on a boolean, this is worth ten minutes of your time.

There are a few open issues and forum threads describing this behavior without a root cause attached. This is what I found.

The symptom

I had a Switch node deciding whether to send an alert. Upstream node output looked like this:

{ “shouldEscalate”: true }

Switch condition: value:

{{ $(‘Extract Response’).first().json.shouldEscalate }}
operator: Boolean → is true
Every execution routed false. No error. Execution reported success. The alert just never fired.

What it wasn’t

I burned time on the wrong hypotheses first, so here they are to save you the trip:

•	Not .first() vs .item — I checked
•	Not a multi-run/item-lineage issue
•	Not the upstream node producing the wrong value

I proved the last one by dropping a Set node directly before the Switch that emitted the raw value plus its type: { “val”: true, “type”: “boolean”, “runs”: 1 }
Genuine boolean. Single run. Correct value arriving at the node. And the Switch still sent it false.

The actual cause

n8n renders {{ }} expression output as text. So a real boolean true arrives at the operator as the string “true” — and if there’s any trailing whitespace or a newline in the parameter field after the closing }} (very easy to introduce when pasting), you get "true ".

The strict Boolean operator gets "true ", which is not a boolean, and the comparison fails. Quietly, in the false direction.

Eventually the node surfaced it verbatim: Wrong type: 'true ’ is a string but was expecting a boolean

Why the obvious fixes didn’t work

I tried converting inside the expression:
{{ String($(‘Extract Response’).first().json.shouldEscalate).trim().toBoolean() }}

Still failed. The whitespace isn’t inside the expression — it’s introduced after }} during n8n’s own rendering. Nothing you do inside the braces can reach it.

Toggling “Convert types where required” changes the failure mode rather than fixing it. In my case one setting routed everything true and the other routed everything false. Both are wrong; one is just louder.

The fix that works
Stop comparing the boolean. Emit an explicit keyword and match on text:
value: {{ $(‘Extract Response’).first().json.shouldEscalate ? “ESCALATE” : “NORMAL” }}
operator: String → contains
match: ESCALATE
convert types: off

"ESCALATE " still contains “ESCALATE”. "NORMAL " doesn’t contain it either way. Trailing whitespace becomes harmless instead of fatal.

Verified in both directions on the same build — condition true routes true, condition false routes false. Worth doing both; testing only the direction you expect is half a test, and this bug is specifically capable of passing one direction while failing the other.

The general version

If a Switch or IF node is making a decision that actually matters — alerting, escalation, compliance gating, anything where silently taking the wrong branch is expensive — don’t route it on a raw boolean. Render a sentinel string and match on it.

“The failure mode here isn’t an error. It’s a green execution that did the wrong thing.”

Solid debugging this one’s brutal because it fails silently and shows green. Your root cause tracks: the value gets rendered to text by the expression engine, and any stray whitespace after }} breaks strict type operators. Trimming inside the braces can’t fix it, since the whitespace shows up after rendering, not before.

Your sentinel-string fix is the right call it’s the most robust way to route on a boolean once it’s passed through an expression. One thing worth adding: you can also try forcing the type on a Set node upstream (using the type selector, not just the expression) before the Switch sometimes that routes through n8n’s internal coercion instead of the text-render path. Not guaranteed to dodge it on every version, but cheap to test.

Good catch this is the kind of bug that costs way more than the ten minutes you’re asking people to spend.

Hi @Tonylw14 Welcome!
The coercion isn’t unconditional, it only happens when the expression sits inside a string. A field holding nothing but {{ ... }} returns the native value, and a single space after }} turns the field into a string template, which is where "true " comes from. Boolean routing works once the field contains the expression and nothing else.
Click into the value field, select all, retype the expression, and leave no space or newline after }}. To verify, copy the Switch node and paste it into a text editor: the parameter should read ={{ $('Extract Response').first().json.shouldEscalate }} and end at the closing braces.
n8n has confirmed this as intended behaviour, an expression embedded in a string gets coerced to a string even when the surrounding string is only whitespace:

One small addition for anyone hitting this, it bites hardest when you copy and paste expressions from Slack/docs/ChatGPT, because they insert a trailing space or a non-breaking space that is almost invisible. If a boolean route fails silently, like Anshul says, writing out the expression by hand is much faster than trying to find the whitespace by staring at the expression.

@Anshul_Namdev @ShawnWilliams — thanks both. That’s a sharper mechanism than what I wrote, and it’s the part I had wrong. I said the coercion was unconditional; it isn’t. A field holding only the expression returns the native value, and anything else — including a single trailing space — makes it a string template, which is what produced** 'true. That also explains why trimming inside the braces did nothing: the coercion happens when the template renders, after the expression has already evaluated.

@ShawnWilliams that’s exactly it. I pasted these expressions from a chat while building. Retyping by hand from now on.

Updating the post with the corrected mechanism and the issue link.

Salman_Mehboob good call on forcing the type on an upstream Set node. Hadn’t tried that path, will test it and report back.

I’m keeping the sentinel string on safety-critical routing, for a reason about the failure mode rather than the mechanism: an invisible trailing space isn’t visible in the UI, and if one gets reintroduced later the branch fails silently on a green execution. A contains match on an explicit keyword is immune to that whole class. But the clean field is the actual fix and should come first.