The Postgres node in n8n: running SQL queries directly from your workflows
Published 28 July 2026 · 7 min read
Many n8n workflows eventually touch a database: saving a lead, checking whether a webhook has already been processed, aggregating sales for a weekly report. App-specific nodes (CRMs, spreadsheets, REST APIs) cover part of those needs, but as soon as the logic becomes relational — joins, aggregations, conditional writes — nothing replaces SQL executed directly against the database. That's exactly what the Postgres node is for: a full SQL client at the heart of the workflow, with ready-made operations for the simple cases and an Execute Query operation for everything else.
Built-in operations or Execute Query: two ways to talk to the database
The Postgres node offers two levels of abstraction. The first is the built-in operations: Insert, Update, Insert or Update (upsert), Select and Delete. You pick a table, n8n reads its schema, and you map the columns to the incoming items' fields — automatically when names match, manually otherwise. No SQL to write, no possible typo in a column name: for writing a lead into a leads table or reading rows matching a simple filter, it's the shortest and safest path.
The second level is the Execute Query operation: a free-text field where you write whatever SQL you need — SELECT with joins, GROUP BY, INSERT … ON CONFLICT, CTEs, function calls. It becomes essential the moment the need goes beyond row-by-row CRUD, because the built-in operations can neither aggregate, nor join, nor express complex conditional clauses.
The practical rule: start with the built-in operations, and switch to Execute Query only when the query demands it. A workflow where every Postgres node contains raw SQL for plain insertions is more fragile (the schema is duplicated across query text) than one that reserves free-form SQL for the places where it genuinely earns its keep.
Query parameters: $1 and $2 instead of concatenated expressions
The natural reflex with Execute Query is to drop n8n expressions straight into the query:
SELECT * FROM orders WHERE email = '{{ $json.email }}'
That's precisely what not to do. The value is concatenated verbatim into the SQL text: a simple O'Brien breaks the query, and a value crafted by a malicious third party — a form field, a webhook payload — can completely hijack its logic. The reference study by Halfond, Viegas and Orso, "A Classification of SQL Injection Attacks and Countermeasures" (IEEE International Symposium on Secure Software Engineering, 2006 — see on Google Scholar), established the taxonomy of these SQL injection attacks and identifies parameterized queries as a first-rank countermeasure: the query and the data travel through separate channels, so the server can never interpret a value as code.
The Postgres node implements exactly this mechanism through the Query Parameters option. You write $1, $2… placeholders in the query, and supply the values separately in the Query Parameters field (comma-separated, expressions allowed):
SELECT id, amount, status
FROM orders
WHERE email = $1 AND status = $2
with {{ $json.email }}, {{ $json.status }} as Query Parameters. Same functional result, but the apostrophe in O'Brien is treated as an ordinary character and an injection attempt stays an inert string. Reserve this for values only: table and column names cannot be parameterized in Postgres — if you need a dynamic table name, validate it against a whitelist in an upstream Code node rather than injecting an expression into it.
Connecting: local Docker, Supabase or a managed server
n8n's Postgres credentials ask for the classics: host, port (5432 by default), database, user, password, and the SSL mode.
Local Postgres in Docker. If n8n and Postgres run in the same docker-compose, the host is not localhost (which refers to the n8n container itself) but the service name — postgres, for instance. It's the most common connection error in self-hosted setups. SSL can stay disabled on an internal Docker network. And if that database holds your production data, our guide on PostgreSQL backup and restore for self-hosted n8n is the essential companion.
Supabase. Every Supabase project is a full Postgres database, reachable with direct SQL. Prefer the connection pooler (host and port shown in the project's connection settings, username of the form postgres.<project-ref>) over the direct connection, which is often unreachable from a server without IPv6. Enable SSL. The detailed setup, credentials included, is covered in our n8n–Supabase connection guide.
Managed server (RDS, Cloud SQL, Scaleway, OVH…): same parameters, with SSL always enabled and, on the provider's side, your n8n instance's IP allowed through the firewall or security rules. When you hit a connection refused error, that network filtering is almost always the first place to look.
Concrete use cases
Aggregation report. The built-in operations can't do a GROUP BY: for a report, Execute Query is the only way.
SELECT date_trunc('week', created_at) AS week,
source, COUNT(*) AS leads, SUM(amount) AS revenue
FROM leads
WHERE created_at >= $1
GROUP BY 1, 2
ORDER BY 1 DESC
Each result row becomes an n8n item, ready to feed an email, a Slack message or a Google Sheet.
Idempotent upsert. A replayed webhook or a rerun scenario must not create duplicates. The Insert or Update operation handles this on a matching column; in free-form SQL, ON CONFLICT gives full control:
INSERT INTO customers (email, name, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name, updated_at = NOW()
Run ten times with the same data, this node produces exactly the same final state — the heart of the pattern detailed in our article on webhook idempotency.
Joins to enrich items. Rather than running one Select per item and gluing the pieces back together with a Merge node (useful in other situations, see our Merge node guide), a single query with a JOIN returns items already enriched: orders with the customer's name, tickets with their history.
Deduplication table. A table processed_events (event_id TEXT PRIMARY KEY) and an INSERT … ON CONFLICT DO NOTHING whose affected-row count you check: zero rows inserted means the event has already been seen and the workflow stops there. Simple, transactional, with no race between concurrent executions.
Transactions and per-item behavior
Like most n8n nodes, the Postgres node runs for each incoming item: ten items feeding an Execute Query means ten executions of the statement, each with its own parameters. The Query Batching option controls how those queries reach the database: grouped into a single call (the fastest), run independently (an error on one item doesn't stop the others — best paired with the node's error handling), or wrapped in a transaction that rolls the whole batch back if a single one fails. That last mode is the right choice when a batch of writes must be applied entirely or not at all — a billing import, for instance. For a multi-step transaction within a single request, a single SQL block (chained CTEs, or an explicit BEGIN/COMMIT inside one Execute Query) remains the most reliable approach.
When to prefer the Supabase node or Data Tables
The Postgres node isn't always the right tool. The Supabase node goes through the project's REST API and respects Row Level Security policies: if your access rules live in RLS, a direct SQL connection with the postgres user simply bypasses them — the Supabase node is then safer for standard CRUD operations, and it's also the building block of RAG architectures with Supabase. Conversely, for free-form SQL (aggregations, ON CONFLICT, joins), the Postgres node plugged into the Supabase pooler remains unmatched.
As for Data Tables, n8n's built-in tabular storage, they're plenty for small internal state: counters, lightweight queues, modest deduplication tables — no credentials, no server, no SQL. Our Data Tables guide draws the line: as soon as you need relations, serious volumes or rich queries, come back to Postgres.
Common pitfalls
- Concatenating
{{ }}expressions into the SQL text instead of using Query Parameters: queries broken by an apostrophe at best, SQL injection at worst, whenever the value comes from a form or a webhook. - Using
localhostas the host in Docker: from the n8n container,localhostdoesn't mean your machine but the container itself. Use the Docker service name (postgres) or the internal network IP. - Connecting to Supabase via the direct connection from a server without IPv6: go through the connection pooler, built exactly for this case.
- Forgetting that the node runs per item: an Execute Query fed 500 items fires 500 queries. Aggregate upstream, or set Query Batching accordingly.
- Expecting the built-in operations to handle aggregation: Select does neither
GROUP BYnor joins — that's an Execute Query case, no need to pile up nodes to work around it. - Using the
postgressuperuser everywhere: create a dedicated role for the workflow, limited to the tables it actually needs, so that a bad query (or an injection that slipped past your vigilance) can't touch the rest of the database.
Going further
The Postgres node is the reliable-write building block par excellence whenever a workflow must leave a durable, queryable trace. That's exactly how the Compliance & Audit Pack (€149) uses it: its audit-logging workflow writes every sensitive event to Postgres/Supabase — timestamp, actor, action — to build a trail you can actually produce during an inspection, an approach detailed in our article on the GDPR audit trail with n8n and Supabase. And once that database becomes the backbone of your automations, backing it up regularly is no longer optional.
FAQ
Frequently asked questions
Why use Query Parameters ($1, $2) instead of {{ }} expressions directly inside the SQL query?
Because an expression inserted into the query text is concatenated as-is: a single apostrophe in a customer name is enough to break the query, and a value crafted by a third party can hijack its logic (SQL injection). With Query Parameters, the query and the values travel separately to the Postgres server, which treats the values as pure data, never as SQL code. It's the reference countermeasure identified by the research literature on SQL injection.
How do I connect n8n to a Supabase database with the Postgres node?
Prefer Supabase's connection pooler over the direct connection: the pooler's host and port are shown in your Supabase project's connection settings, with a username of the form postgres.<project-ref>. The direct database connection is often unreachable from a server without IPv6, which explains most connection errors. Enable SSL in the n8n credentials, as you would for any Postgres exposed to the Internet.
Does the Postgres node run one query per item or a single query for all items?
By default, the Execute Query statement runs for each incoming item, which can multiply round trips to the database. The Query Batching option lets you choose: send all queries in a single call, run them independently (one error doesn't stop the others), or wrap them in a transaction that rolls everything back on failure. That last mode is invaluable when a batch of writes must be applied entirely or not at all.
When should I prefer the Supabase node or Data Tables over the Postgres node?
The Supabase node goes through the project's REST API and respects Row Level Security policies: it's the right choice when you want those access rules enforced rather than bypassed by a direct SQL connection. Data Tables, for their part, are enough for small internal storage needs (state, counters, simple deduplication tables) with no credentials or server to manage. The Postgres node remains essential whenever you need free-form SQL: aggregations, joins, upserts with ON CONFLICT.
Bundle FlowKit Complet
€269