#40022·n8n

Postgres node: a comma in a query-parameter value is split into several bind values

Author: blercherCreated Sep 30, 2026Updated Oct 1, 2026
Labelsteam:nodesstatus:in-linearstatus:team-assigned

⚠️ Severity corrected after further testing. An earlier version of this report claimed silent data corruption. That was wrong: I had verified n8n's value collection but not the value binding. Supplying the surplus value to PostgreSQL produces a hard error, not a wrong write. See the correction comment. The code path and the fix suggestion are unchanged.

Affected version: 2.41.3 — and current master. The code is byte-identical in both (verified against [email protected] and master: same statements, same line numbers). Node version: typeVersion 2.5 and above, i.e. the current branch. Impact: the query fails with a parameter-count error that does not point at the real cause. No incorrect data is written.

Summary

The Postgres node tells users to use $1, $2, … with Query Parameters to avoid SQL injection. When a parameter's evaluated value is a string containing a comma that is not itself valid JSON, the node splits that one value into several bind values. The resulting parameter count no longer matches the statement, and execution fails with an error that blames the count rather than the comma in the user's data.

Affected code

packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts, inside the nodeVersion >= 2.5 branch (lines 106–108 in both 2.41.3 and master):

typescript
const evaluatedExpression = evaluateExpression(rawEvaluated);
const evaluatedValues = isJSON(evaluatedExpression)
    ? [evaluatedExpression]
    : stringToArray(evaluatedExpression);

if (evaluatedValues.length) values.push(...evaluatedValues);

stringToArray (helpers/utils.ts) is a comma split with empty filtering and trimming:

typescript
export function stringToArray(str: NodeParameterValueType | undefined) {
	if (str === undefined) return [];
	return String(str)
		.split(',')
		.filter((entry) => entry)
		.map((entry) => entry.trim());
}

So a single parameter whose evaluated value is O'Brien, Test contributes two bind values instead of one.

Why isJSON() does not save you

evaluateExpression (helpers/utils.ts) returns plain strings via toString() — it does not JSON-encode them:

typescript
return typeof expression === 'object' ? JSON.stringify(expression) : expression.toString();

So isJSON() returns true only when the data itself happens to parse as JSON:

evaluated value isJSON bind values produced correct?
123 true 1 yes
true true 1 yes
Doe, John false 2 no
O'Brien, Test false 2 no
hello, world, again false 3 no

This is why casual testing tends to pass: a numeric or boolean-looking value behaves correctly, while ordinary free text does not. It is data-dependent, which is the most dangerous shape for a bug in a form/webhook ingestion path.

Reproduction

  1. Add a Postgres node, operation Execute Query, with Query Parameters on.
  2. Query: SELECT $1::text AS a, $2::text AS b — two placeholders.
  3. Query parameters, in the node's options.queryReplacement:
    • ={{ $json.name }}
    • ={{ $json.note }}
  4. Feed webhook/Code-node data: { "name": "Doe, John", "note": "hello" }
  5. Execute.

Verified — value collection, using the node's own logic

isJSON, evaluateExpression and stringToArray copied verbatim from helpers/utils.ts, plus the nodeVersion >= 2.5 branch copied verbatim, mocking only getResolvables() and the expression evaluator. Observed bind values:

no commas   {name:"Smith",      note:"hello"}                 -> ["Smith","hello"]           correct
comma in $1 {name:"Doe, John", note:"hello"}                 -> ["Doe","John","hello"]       3 values, 2 placeholders
3 params    {name:"Doe, John", note:"hello", city:"Galena"}   -> ["Doe","John","hello","Galena"]  4 values, 3
legacy <2.5 {name:"Doe, John", note:"hello"}                 -> ["Doe, John","hello"]         SAFE
array       {tags:["a","b","c"]}                             -> ["a","b","c"]                 #31704 fix present

Verified — what the database then does

BEGIN;
CREATE TEMP TABLE reprobind (a text, b text);
PREPARE two (text, text) AS INSERT INTO reprobind VALUES ($1, $2) RETURNING a, b;
EXECUTE two('Doe', 'John', 'hello');
ERROR:  wrong number of parameters for prepared statement "two"
DETAIL:  Expected 2 parameters but got 3.

The driver rejects the surplus value; it does not drop it and does not shift the remaining bindings.

This can never be silent. stringToArray splits on commas and filters empties, so a value containing a comma can only ever add to the parameter count, never reduce it. values.length is therefore always >= the number of expressions supplied, so the count either overshoots the placeholders ("Expected N but got M") or undershoots them (the mirror error). It can never accidentally re-align.

Note on the nodeVersion < 2.5 branch

The older branch splits the raw parameter string (rawReplacements.replace(/^=+/, '').split(',')) and then pushes each evaluated result whole:

typescript
for (const rawValue of rawValues) {
    const resolvables = getResolvables(rawValue);
    if (resolvables.length) {
        for (const resolvable of resolvables) {
            values.push(this.evaluateExpression(`${resolvable}`, index) as IDataObject);
        }
    } else {
        values.push(rawValue);
    }
}

Because the split is applied before evaluation, commas inside the data are safe there. The hazard is in the newer branch, where the split is applied after evaluation.

Relationship to #30978 / #31704

#30978 reported that a JS array was wrapped as a single bind value because of the same isJSON() mismatch, and #31704 (merged 2026-06-04) fixed it by spreading arrays. That fix handles Array.isArray(rawEvaluated) and does not change the isJSON() ? … : stringToArray(…) path for strings, so the comma case described here remains. This looks like the remaining half of the same underlying problem: the node cannot reliably distinguish "a string that is one value" from "a comma-separated list of values".

Suggested fix

Keep the ability to pass several literal parameters, but never split a value that came from an expression. Concretely, in the nodeVersion >= 2.5 branch, when the raw parameter contained a resolvable, push the evaluated result as exactly one value, and reserve stringToArray for parameters that are literal text with no {{ }}:

typescript
if (resolvables.length) {
    for (const resolvable of resolvables) {
        const rawEvaluated = this.evaluateExpression(`${resolvable}`, index);
        if (Array.isArray(rawEvaluated)) {
            for (const item of rawEvaluated) { /* unchanged */ }
            continue;
        }
        // an expression evaluates to exactly one value
        values.push(evaluateExpression(rawEvaluated));
    }
} else {
    values.push(...stringToArray(rawValues));
}

If comma-splitting of literal parameters must be preserved for backwards compatibility, that is the branch to keep it in. The current behaviour appears to be unintentional: a single expression is expected to yield a single bind value everywhere else in the node.

Happy to open a PR with tests if that is useful.

Environment

  • n8n 2.41.3, self-hosted
  • Postgres node typeVersion 2.5
  • Observed on a form/webhook ingestion workflow storing free-text customer input