data/skills/n8n-node-configuration/NODE_FAMILY_GOTCHAS.md
Silent-failure traps grouped by node family. These don't show up in validate_node or validate_workflow — the workflow validates clean, runs without error, and quietly does the wrong thing. get_node shows you the fields exist; it doesn't tell you what happens when you leave them off. This file covers the consequence.
Each entry: symptom (what you see at runtime), cause (why), fix (in n8n-mcp / JSON terms).
Symptom: items that match none of the rules vanish. No error, no warning — the workflow just loses data on the unmatched path.
Cause: without a fallback output, the Switch has nowhere to send unmatched items, so it discards them.
Fix: set options.fallbackOutput: "extra" and give it a name with options.renameFallbackOutput. While you're there, name every rule output too — unnamed 0 / 1 / 2 outputs are unreadable a month later, and a failure on "output 2" tells the operator nothing.
{
"parameters": {
"mode": "rules",
"rules": {
"values": [
{ "outputKey": "Paid", "renameOutput": true, "conditions": { "...": "..." } },
{ "outputKey": "Refunded", "renameOutput": true, "conditions": { "...": "..." } }
]
},
"options": {
"fallbackOutput": "extra",
"renameFallbackOutput": "Unexpected"
}
}
}
Apply surgically with patchNodeField on parameters.options.fallbackOutput, or with updateNode for the full options object. After wiring, confirm the fallback branch goes somewhere real (a log, an alert, a NoOp) — an enabled fallback that connects to nothing drops items just the same.
Two traps, both silent. They live on different Merge modes — numberOfInputs on Append/Combine, useDataOfInput on Choose Branch — so in practice you hit one or the other, not both.
Symptom: you wire 3+ sources into a Merge, the canvas shows three wires going in, the workflow validates and runs — but only the first two sources' items appear downstream. The third silently drops.
Cause: numberOfInputs defaults to 2. The third wire connects to an input slot that doesn't exist on the node.
Fix: set numberOfInputs to match your wire count.
{ "parameters": { "mode": "append", "numberOfInputs": 3 } }
Verify with get_node for the merge node on the user's n8n version — the field name has shifted across versions. After building, pull the workflow with n8n_get_workflow and confirm parameters.numberOfInputs matches the number of source entries in the connections object feeding it.
useDataOfInput is 1-indexed, connections are 0-indexedSymptom: the Merge passes through the wrong source. Downstream gets real data with real field names — just from the wrong upstream branch. Looks identical to a working flow; the shape is right, the contents are wrong.
Cause: parameters.useDataOfInput matches the UI labels (Input 1, Input 2, Input 3 — 1-indexed), but the wiring position in connections.<source>.main[idx] is 0-indexed like every other array. Off by one.
Fix — the translation rule:
useDataOfInput: "N"is fed by the connection atmain[N-1].
useDataOfInput | Connection slot |
|---|---|
"1" | connections.<source>.main[0] |
"2" | connections.<source>.main[1] |
"3" | connections.<source>.main[2] |
When you add the connection via n8n_update_partial_workflow, the addConnection operation targets a specific input index. To pass through Input 2, the source whose data you want must land on the connection at main[1]. After wiring, verify with n8n_get_workflow: read the connections object and confirm the source you intend to pass through actually sits at main[N-1]. This is the only reliable check — it won't surface in validation.
Covers Postgres, MySQL, and Supabase (when used via the Postgres node against the same database). The exact field set differs per node and version — get_node is canonical. This is the security and behavior layer it doesn't show.
Symptom: the query works in testing, then a value containing a quote or ; produces a SQL error — or worse, executes injected SQL. $json.email = "x'; DROP TABLE users; --" is game over.
Cause: n8n substitutes {{ ... }} expressions into the query text before the database driver binds parameters. Anything inside {{ }} becomes part of the SQL itself, not a bound value.
Fix: use $1, $2, ... placeholders in the query and pass values through options.queryReplacement. The values flow through the driver's parameter binding and never touch the SQL text. (The n8n MySQL node also uses $1, $2 + queryReplacement, not MySQL's native ? — the node normalizes to the driver.)
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT * FROM users WHERE email = $1",
"options": {
"queryReplacement": "={{ $json.email }}"
}
}
}
queryReplacement takes a comma-separated list — each piece becomes one parameter: ={{ $json.email }},={{ $json.id }} → $1, $2. The = prefix is just n8n's expression-mode marker. Treat any DB node with a {{ ... }} expression inside parameters.query as a critical injection finding.
Symptom: two separate DB nodes, the second fails, and the first's write is already committed — no rollback.
Cause: there is no cross-node transaction in n8n. Atomicity is bounded to a single executeQuery invocation.
Fix: for atomic multi-step writes, put all the statements in one Postgres/MySQL executeQuery node and set options.queryBatching: "transaction" explicitly — don't rely on the default, which has shifted across node versions (single-query and independent batching are the other modes; confirm the current set and default with get_node). Everything that node runs in that execution goes through one BEGIN/COMMIT; any failure rolls it all back. Pre-compute lookups and derived values upstream so the transactional node receives ready-to-write data.
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO orders (customer_id, total) VALUES ($1, $2)",
"options": {
"queryBatching": "transaction",
"queryReplacement": "={{ $json.customerId }},={{ $json.total }}"
}
}
}
Supabase's REST layer has no transactions — drop to the Postgres node connected directly to the same database when you need atomicity.
Symptom: a select / executeQuery that matches nothing returns zero items, and the downstream node simply doesn't run — looks like the branch was skipped.
Cause: zero matched rows = zero n8n output items, and most nodes treat "no input items" as "nothing to do."
Fix: set alwaysOutputData: true on the DB node so a single empty item flows through, then branch on the result with an IF. (This is the same gotcha as write operations — INSERT/UPDATE/DELETE often return 0 items too; alwaysOutputData: true keeps the chain alive.)
The exact param shapes shift across versions — get_node for nodes-base.slack is canonical. These are the traps it won't warn you about.
Symptom: you pass a Block Kit array, the request succeeds, but the message arrives as plain text (or empty). No node error, no validation warning.
Cause: the node accepts a bare array silently and drops the rich content. Slack's chat.postMessage expects { "blocks": [...] } — an object with a blocks key — and the node forwards your value as-is.
Fix: wrap the array in an object, in expression mode so the node receives a real object (not a stringified one). Reference the source by node name, not $json:
={{ { "blocks": $('Build Message').item.json.blocks } }}
Don't stringify-then-reparse hybrids ({{ ... .toJsonString() }} glued into a string) — they work on some versions but break on escaping and large payloads. Hand the node the structure directly.
thread_tsSymptom: a "reply" posts as a new top-level channel message instead of in the thread.
Cause: without thread_ts (the timestamp of the message being replied to), Slack has no thread to attach to.
Fix: set thread_ts to the parent message's ts. Use get_node to find where the field sits on the current version — it moved out of otherOptions where older docs put it. Add reply_broadcast: true if the reply should also show in the main channel.
Symptom: you set operation: "send" (matching the UI's "Send a message") and validation rejects it.
Cause: the display label and the stored value diverge. "Send a message" is operation: "post", not "send".
Fix: read the real operation values from get_node for nodes-base.slack rather than guessing from the UI label. This display-vs-value mismatch recurs across resource nodes (e.g. "Get Many" → getAll on Gmail/Supabase).
Entry and exit of request/response API workflows. get_node is canonical for field shapes; this is the runtime behavior it doesn't show.
Symptom: an error branch returns HTTP 200 with an error body. The caller's HTTP client sees success while the body says failure — the worst of both worlds, because the caller's error handling never fires.
Cause: responseCode defaults to 200 on every Respond to Webhook node, including the ones you wired to error paths.
Fix: set responseCode explicitly on every Respond branch — 4xx for caller errors (400 validation, 401/403 auth, 409 conflict, 429 rate limit), 5xx for server errors. A workflow can have multiple Respond nodes, one per response shape; n8n returns whichever fires first.
responseMode: "responseNode" for real request/response APIsSymptom: the caller gets an immediate 200 and never sees the workflow's actual output, even though the workflow computes a response.
Cause: the Webhook trigger's responseMode defaults to onReceived (acknowledge immediately, run async). The caller can't see downstream results.
Fix: set parameters.responseMode: "responseNode" on the Webhook trigger and control the response with explicit Respond to Webhook nodes. (lastNode returns the last node's output synchronously — fine for simple cases; responseNode is the flexible choice for multi-status APIs.)
respondWith: "json" takes the object, not a stringified stringSymptom: the response body comes back double-encoded — escaped quotes, a JSON string wrapped in another JSON string.
Cause: the responseBody field accepts both an object and a string. If you pass JSON.stringify(obj), n8n serializes that string again.
Fix: pass the object directly in expression mode and let the node serialize it once:
={{ { "status": "ok", "id": $('Create Record').item.json.id } }}
get_node for nodes-base.scheduleTrigger shows the rule structure. These are the behaviors outside the type def.
Symptom: a job that should fire at 9am local drifts after a DST change or an instance move.
Cause: the Schedule Trigger uses the workflow's timezone (Workflow Settings → Timezone). There is no timezone field inside a rule. Without an explicit workflow timezone, it follows the host's clock.
Fix: set the workflow timezone explicitly for any schedule that must run at a specific local time. The per-rule config has no timezone to set — don't look for one.
Symptom: confusion over whether a cron expression needs a seconds field — the UI hint shows 6 fields, the placeholder shows 5.
Cause: n8n's cron supports both 5-field (Minute Hour DoM Month DoW) and 6-field (Second Minute Hour DoM Month DoW) formats. Both are valid.
Fix: use whichever you intend; just be consistent. For simple recurrences ("every Monday 9am"), the interval modes (field: "weeks" etc.) are clearer and less error-prone than cron.
Symptom: an instance restart or downtime window overlapping a scheduled time, and that run never happens.
Cause: schedules fire against the instance's clock. If the instance is down at fire time, the run is simply skipped — there's no catch-up queue.
Fix: for business-critical schedules, make the workflow idempotent (running it twice produces the same result) and, where it matters, detect missed runs at workflow start by comparing the last successful run to the expected cadence and catching up.