The n8n docs for the Postgres node say you can use $1, $2 and $3 in a query "to build prepared statements to use with query parameters." On September 18 I found out what that means in practice, from a failure that my own dry-run had told me couldn't happen.
The query that passed in psql
I was running an end-to-end test of a workflow I maintain that books a callback. One Postgres node stores when the callback is due, and the number of minutes comes from earlier in the flow. Sometimes there is no number, so the value is an empty string. Simplified, the node's query looked like this:
SELECT CASE
WHEN $1 = '' THEN NULL
ELSE make_interval(mins => $1::int)
END AS delay;
Before deploying, I had tested it in psql the way I'd test any parameterized query:
PREPARE p(text) AS
SELECT CASE WHEN $1 = '' THEN NULL
ELSE make_interval(mins => $1::int) END AS delay;
EXECUTE p(''); -- returns NULL
NULL, as expected. The CASE sees the empty string and never reaches the cast.
In n8n, the same node failed with invalid input syntax for type integer: "". I caught it only because I was watching that test step by step. The node had On Error set to Continue (using regular output), so the execution finished green. No retry, no alert, and no callback saved.
What n8n actually sends
The Postgres node runs on pg-promise, pinned at 11.9.1 in n8n 2.41.6, the latest stable release as I write this. pg-promise has its own formatting engine, and its README says the engine is "used by default with all query methods, unless you opt out of it entirely via option pgFormatting." n8n creates its pg-promise instance without that option.
So the values never travel as bind parameters. pg-promise escapes them and writes them into the SQL text on the client, and Postgres receives one plain string. I reproduced it this morning against a local Postgres 16.15 with the same pg-promise version:
const pgp = require('pg-promise')();
const sql = "SELECT CASE WHEN $1 = '' THEN NULL ELSE make_interval(mins => $1::int) END AS delay";
console.log(pgp.as.format(sql, ['']));
SELECT CASE WHEN '' = '' THEN NULL ELSE make_interval(mins => ''::int) END AS delay
Run that against the database and you get error 22P02, invalid input syntax for type integer: "". Send the same SQL with the same value as a real parameter (pg-promise's ParameterizedQuery) and it returns {"delay": null}, exactly like my psql test.
To be fair to n8n, the part of the docs that says it "sanitizes data in query parameters, which prevents SQL injection" holds up. The escaping is real. What changes is how Postgres types the value.
Why the CASE didn't help
$1::int on a real parameter is a run-time cast. It runs when that branch runs, and in my test that branch never ran.
''::int is something else. The Postgres docs on type casts put it this way: "A cast applied to an unadorned string literal represents the initial assignment of a type to a literal constant value." Postgres hands the string to the integer input routine while it is still analyzing the statement, before it evaluates anything. An empty string isn't valid integer input, so the statement fails before the CASE gets a say. psql points the caret right at it:
ERROR: invalid input syntax for type integer: ""
LINE 1: ...WHEN '' = '' THEN NULL ELSE make_interval(mins => ''::int) E...
^
The section on expression evaluation rules says it plainly: CASE "is not a cure-all," and it "does not prevent early evaluation of constant subexpressions." Once your parameters are pasted in as literals, a lot more of your query is constant than it looks.
The first half of the lesson: commas
That workflow had already burned me once through the same Query Parameters field. Earlier, a free-text value with a comma in it shifted every parameter after it. The row was saved without an error, and every value after the comma had moved one column over.
The reason is in executeQuery.operation.ts. For node version 2.5 and up (new nodes get 2.7), n8n evaluates each {{ }} in the Query Parameters field separately. If the result is an array, it takes the items one by one. Anything else becomes a string, and unless that string happens to parse as JSON, it goes through stringToArray:
return String(str)
.split(',')
.filter((entry) => entry)
.map((entry) => entry.trim());
Split on commas, drop the empty pieces, trim the rest. The docs do describe the field as "a comma-separated list of values." They don't mention that the split happens after your expressions are evaluated, inside your data.
Here is what that logic does to one item. I ran a copy of those exact functions:
// Query Parameters: {{ $json.minutes }},{{ $json.note }},{{ $json.id }}
// item: { minutes: '', note: 'call back, after 5pm', id: 7 }
// values -> ["call back", "after 5pm", "7"]
The empty minutes disappeared, and the note became two values. One value vanished and one appeared, so the count still matched three placeholders and nothing failed:
SELECT 'call back'::text AS minutes, 'after 5pm'::text AS note, '7'::int AS id
-- {"minutes":"call back","note":"after 5pm","id":7}
This is the version that worries me more. The ''::int error at least stops the node. A shifted row looks fine until somebody reads it.
What I changed
1. Query Parameters is always one array expression.
{{ [ $json.minutes, $json.note, $json.id ] }}
When the whole field is one expression that returns an array, n8n uses that array as the parameter list and nothing gets split. Commas stay inside their value, and empty strings keep their position. It's also the form the n8n docs themselves use for IN (...) lists.
2. Every cast on a value that can be empty goes through NULLIF.
SELECT make_interval(mins => NULLIF($1, '')::int) AS delay;
After formatting, the empty case becomes make_interval(mins => NULLIF('', '')::int), which returns NULL, and with '15' it returns a 15-minute interval. Both ran clean in the reproduction. NULLIF turns the empty string into NULL before the cast, and NULL cast to an integer is just NULL.
3. Dry-runs test the SQL that n8n will send, not a PREPARE.
I format the query with pg-promise first and run the output inside a transaction that I roll back:
const pgp = require('pg-promise')();
console.log(pgp.as.format(sql, values)); // run it in psql between BEGIN; and ROLLBACK;
My PREPARE test was correct about a query that n8n never runs.
4. On Error → Continue stays off the node that writes the thing that matters.
The node that saves the callback now fails loudly, with Retry On Fail set to three tries. Continue is fine on a step whose failure can't hide anything downstream. On that node, it turned a failure into a quiet success.
The short version
If you write SQL in n8n's Postgres node, assume your values get pasted into the text as quoted literals, because they do. A cast on one of those literals happens when Postgres analyzes the statement, whatever branch it sits in. And a comma inside a value is a parameter boundary unless you pass an array.
I build and maintain n8n integrations for businesses at Achiya Automation, and these four rules now get checked before any Postgres node goes live.
How do you fill Query Parameters in your n8n workflows, comma string or array? If it's the comma string, have you checked what happens when one of the values contains a comma?
Top comments (1)
One detail that didn't fit in the post: the comma split also trims every piece. So a value whose leading or trailing spaces matter loses them on the way in, even when it has no comma at all. The array form keeps those too.
Has anyone found other value shapes that change on their way through Query Parameters?