n8n Postgres vs SQLite: The Queue Mode Benchmark
We ran queue mode on SQLite so you don't have to. Here is the full damage report.
The verdict up front: n8n's own documentation draws the line bluntly — Postgres 13+ is recommended for queue mode, and running queue mode on SQLite "isn't recommended". A second page in the same docs goes further and marks SQLite as not supported for queue mode. After two weeks of load-testing both backends on a staging cluster, our conclusion matches the docs and adds the mechanism: SQLite is a single-writer database, and queue mode is a many-writer architecture. Every benchmark dimension below traces back to that one structural mismatch. The vendor recommendation is the Zero-Glue Theorem in documentation clothing: Postgres draws a native multi-writer boundary between the four process types, while SQLite forces every one of them through a single file-level lock — glue that only shows itself under load.
TL;DR for operators:
- Queue mode = main process + N workers + webhook processors, all hammering one shared database with reads and writes. That is the architecture SQLite was never built for.
SQLITE_BUSY: database is lockedis not a config bug. It is the expected outcome of the single-writer design under multi-process load. WAL mode delays it; it does not remove it.- The Postgres side has its own trap almost nobody mentions:
DB_POSTGRESDB_POOL_SIZEdefaults to 2 per process. At concurrency 50, fifty executions write through two connections. Your workers idle while the pool queues. - Migration is a 30-minute maintenance window with five CLI commands — but execution history does not survive the trip. Plan for it.
- What Queue Mode Actually Does to a Database
- The Five-Dimension Benchmark
- Dimension 1: One Writer vs. Many
- Dimension 2: WAL Mode Buys You Readers, Not Writers
- Dimension 3: The Pool-Size Math Nobody Shows You
- Dimension 4: Execution Data Growth and Pruning Under Load
- Dimension 5: The Load Class Each Backend Was Built For
- Our Staging Lab Results
- When SQLite Is Still the Right Answer
- The Migration Runbook: SQLite to PostgreSQL, Verified Commands
- The Production Compose File
- Failure Mode Playbook
- The Pool Calculator
- Frequently Asked Questions
- Where This Fits in the Scaling Path
- Related Cluster Intelligence
What Queue Mode Actually Does to a Database
Regular mode is one Node.js process. UI, API, webhooks, executions — all in-process, all one database client. SQLite is fine there.
Queue mode tears that apart. The official architecture: the main instance receives triggers and webhook calls, creates an execution record, and pushes the execution ID onto a Redis (Bull) queue. A worker pops the job, fetches the full workflow definition and credentials from the database, executes every node, writes the results back to the database, and signals Redis. Add webhook processors and you have four process types sharing one database as the single source of truth.
Count the write paths: the main process writes execution status rows as things queue up. Every worker writes results rows as things finish. Pruning deletes old rows on a timer. The editor reads everything, constantly.
Now add the constraint. SQLite permits exactly one write transaction against the file at a time. Not one per process. One, total. When worker A commits execution results while worker B commits its own, one of them waits — and if the wait outlives the busy timeout, n8n logs SQLITE_BUSY: database is locked and the execution write fails.
That is the entire benchmark in one paragraph. The five dimensions below are just this failure wearing different costumes.
The Five-Dimension Benchmark
| Dimension | SQLite (default / WAL) | PostgreSQL 13+ | Edge |
|---|---|---|---|
| Concurrent write model | Single file-level writer; SQLITE_BUSY under contention |
MVCC, row-level locking | Postgres, by design |
| Read concurrency under load | WAL helps readers only; NFS breaks WAL | No file-level constraint | Postgres |
| Connection scaling | No real pool; DB_SQLITE_POOL_SIZE gates WAL readers |
DB_POSTGRESDB_POOL_SIZE (default 2 — must be tuned) |
Postgres, after tuning |
| Execution data lifecycle | Prune competes with writes on the same single lock | Prune is just another writer | Postgres |
| Documented load class | "Not recommended" for queue mode (official) | Up to 220 executions/sec on a single instance (official perf tests) | Postgres |
One row deserves its own honesty note: the docs say two different things in two places. The queue mode page says SQLite "isn't recommended". The database selection guidance marks queue mode as requiring Postgres — SQLite listed as not supported. We treat the stricter wording as the real one, because the mechanism (single-writer vs many-writer) explains why "not recommended" is generous.
Dimension 1: One Writer vs. Many
This is the dimension that ends the debate.
SQLite's concurrency story is "readers can share, writers queue." Under regular mode — one process, modest volume — the writer queue never gets long enough to hurt. We ran SQLite in regular mode for months without a single lock error.
Queue mode changes the shape of the load. Two workers at concurrency 10 each means up to twenty executions finishing at overlapping times, each one writing execution_entity rows and result data. The editor is open in another tab. The prune job fires. That is six write sources against one file-level lock.
The failure signature is consistent and boring, which is the worst kind of failure. No crash, no corruption dump. Just SQLITE_BUSY: database is locked in the logs, executions flipping to error state, and a retry storm that makes the lock contention worse. In our staging lab (environment disclosed in the results section below), a 2-worker × 10-concurrency queue on SQLite produced lock errors within the first hour of sustained load. Same workload on PostgreSQL 16: zero lock errors, and we stopped watching after a week.
The community pattern matches ours. Forum threads on horizontal scaling converge on the same story — SQLite works until the second process appears.
Dimension 2: WAL Mode Buys You Readers, Not Writers
The standard advice for SQLite lock pain is "enable WAL." In n8n you do it with DB_SQLITE_POOL_SIZE — set it above zero and n8n opens the database in Write-Ahead Logging mode, with the value controlling how many parallel read connections it opens.
WAL is a genuine improvement. Readers stop blocking on the writer. Editor snappiness improves immediately.
But read the fine print on what WAL actually changes. Writers still serialize. WAL lets readers proceed while a writer holds the lock; it does nothing to let two writers proceed together. In queue mode, the writers are the workload. Optimizing the read path on a write-bound problem is tuning the wrong dimension.
Two more WAL footnotes that bite in production:
- NFS breaks WAL. WAL needs shared-memory files (
.shm) on a filesystem that supports them properly. Network volumes frequently do not. If your SQLite file lives on an NFS mount, WAL degrades or fails outright. DB_SQLITE_VACUUM_ON_STARTUP=truerebuilds the database file to fight fragmentation — and it is a blocking operation that extends startup. Useful for a small single-instance setup. Irrelevant if you are heading to queue mode anyway.
Our benchmark verdict on WAL: worth enabling for any SQLite instance (it is closer to free), but it moves the wall. It does not remove it.
Dimension 3: The Pool-Size Math Nobody Shows You
Switch to Postgres and you inherit a different trap, and this one hides in a default value.
DB_POSTGRESDB_POOL_SIZE defaults to 2. Every n8n process — main, each worker, each webhook processor — opens exactly two Postgres connections, no matter what concurrency you set. An n8n community scaling thread laid out the consequence precisely: at concurrency 50, fifty concurrent executions push their execution-data writes through those two connections, and everything after the first two waits.
The fix is arithmetic, and it must be arithmetic you do before the incident:
pool per process ≈ concurrency per process (clamped by your Postgres budget)
total connections = processes × pool — and this total has to fit inside Postgres max_connections (default 100) with headroom for admin tools, pgbouncer, and the OS.
Worked example, 4 workers at concurrency 10:
- pool = 10 per process (clamped at our recommended ceiling of 12)
- total = 4 workers × 10 + main at 10 = 50 connections
- 50 ≤ 80 (80% of 100) → green, with 30 connections of headroom against the budget
The interactive calculator near the end of this article does this math for your numbers. Every value it produces matches the worked examples in this section — we checked.
One more variable in the same family: on memory-tight hosts, each Postgres connection costs roughly 10 MB of RAM, which is why tiny VPS deployments sometimes lower the pool deliberately. Sizing is a triangle — concurrency, connections, RAM — and you pick which corner gives.
Dimension 4: Execution Data Growth and Pruning Under Load
Every execution writes rows into the database. At 220 executions per second — the load class Postgres handles on a single instance — you are generating execution data faster than most disks can absorb it, so the retention dials matter as much as the backend.
The dials:
EXECUTIONS_DATA_PRUNE=true— turn pruning on. In production. Always.EXECUTIONS_DATA_MAX_AGE=336— the default; execution data older than 336 hours (14 days) becomes pruning fodder.EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000— whichever limit hits first, age or count, prunes.EXECUTIONS_DATA_SAVE_ON_SUCCESS=none— the aggressive option. Successful executions stop being persisted at all; errors are still saved. Operators running this report on the order of 90% less execution data. Treat that figure as practitioner-reported, not an official spec.
Now the queue-mode angle, and it is the same single-writer story one more time. On SQLite, the prune job is another writer fighting workers for the one file lock — pruning under load makes your lock errors worse at exactly the moment load is high enough for pruning to matter. On Postgres, pruning is just one more writer among many, and MVCC lets it proceed without blocking the workers.
A second queue-mode footnote from the official docs, easy to miss: binary data in the filesystem is not supported with queue mode. If your workflows handle files, you need S3-compatible external storage (N8N_DEFAULT_BINARY_DATA_MODE=s3) regardless of which database you pick.
Dimension 5: The Load Class Each Backend Was Built For
The official n8n performance work puts a single instance with a Postgres backend at up to 220 workflow executions per second. Whatever your opinion about SQLite, that number defines the target load class for queue mode, and nobody is hitting 220 exec/s through a single file-level write lock.
There is a quieter signal in how n8n itself ships the product: n8n Cloud's enterprise-scale tiers run on Postgres, while smaller tiers run on SQLite — the vendor's own hosted product reserves Postgres for the multi-process, high-concurrency cases. A vendor's pricing page is a form of documentation.
SQLite is not "bad." It is the most widely deployed database on earth, and inside one n8n process it is superb. The benchmark verdict is narrower than that: SQLite is the right engine for one process, and queue mode is N processes.
Our Staging Lab Results
Full disclosure of how we tested, so you can judge the numbers yourself:
- Environment: 4 vCPU / 8 GB VPS, PostgreSQL 16 and Redis 7 in Docker, n8n pinned to a current 2.x release, workload = a 12-node webhook-triggered workflow writing result data, driven by a load generator.
- Method: same workload against SQLite (default journal, then WAL) and PostgreSQL 16, 2 workers × concurrency 10, one hour sustained per configuration, three runs each.
| Metric (1h sustained, 2×10 concurrency) | SQLite default | SQLite WAL | Postgres 16 | Delta (Postgres vs SQLite default) |
|---|---|---|---|---|
database is locked errors |
40+ | 6–12 | 0 | −100% (40+ → 0) |
| Failed executions from lock contention | yes | occasional | none | Failure mode eliminated |
| Editor responsiveness under load | degraded | acceptable | unaffected | Restored to idle baseline |
| Prune job during load | worsened locks | worsened locks | no visible impact | Write contention removed |
These are our staging numbers on our workload — self-tested, not vendor specs. Your workflow shape will move the absolute values. The shape of the result — errors collapsing to zero only on the Postgres column — is the part we expect to replicate, because the mechanism is architectural, not environmental.
When SQLite Is Still the Right Answer
An honest benchmark includes the counter-case, and SQLite's counter-case is real:
- Regular mode, single instance. One process means one writer. SQLite's design constraint simply never engages.
- Low concurrent volume. A handful of executions per minute with no overlapping long-running workflows fits comfortably.
- Zero-ops deployments. No second database server to back up, patch, monitor, or pay for. The
.n8nfolder is the database. - Development and CI. Fast, disposable, file-copyable.
The honest trigger list for switching: you are planning queue mode (or multi-main, which additionally requires an Enterprise license), database is locked has appeared in your logs even once, or your editor starts lagging while executions run. Any one of the three is enough. You do not need to hit a specific executions-per-day number — n8n deliberately declines to publish one, tying the decision to architecture instead.
The Migration Runbook: SQLite to PostgreSQL, Verified Commands
The move is export-and-import, not a file conversion. Budget a maintenance window — n8n is briefly down during the switch. These are the commands, in order:
1. Back up workflows and credentials from the SQLite instance:
n8n export:workflow --all --output=backup/
n8n export:credentials --all --decrypted --output=backup/
The decrypted credentials file is plaintext secrets. Move it somewhere non-public immediately, and delete it from the server after the import. This is the single most dangerous artifact the migration creates.
2. Stand up PostgreSQL 13+ with a dedicated database and user. n8n creates its schema on first start — do not hand-create tables.
3. Point n8n at Postgres:
DB_TYPE=postgresdb
DB_POSTGRESDB_HOST=your-postgres-host
DB_POSTGRESDB_PORT=5432
DB_POSTGRESDB_DATABASE=n8n
DB_POSTGRESDB_USER=n8n
DB_POSTGRESDB_PASSWORD=your-password
4. Start n8n against the empty database, let it build the schema, then import:
n8n import:workflow --separate --input=backup/
n8n import:credentials --separate --input=backup/
5. Spot-check workflows before re-enabling triggers — imports are mechanical, but the thing you are verifying is that credentials decrypt, and only a test execution proves that.
Two limits to plan around, so they don't surprise you mid-window: execution history is not migrated by this path, and shared N8N_ENCRYPTION_KEY handling matters if you were already running multiple processes — every worker must hold the same key or stored credentials decrypt to garbage.
The Production Compose File
A minimal queue-mode stack — Postgres, Redis, main, one worker — with the env block anchored once and shared, because duplicating env blocks across four services is how drift starts. Every custom anchor lives inside an x- extension field, so the file passes the strict Compose Spec validation that rejects bare top-level keys — the exact failure that quietly breaks copy-pasted n8n stacks:
x-n8n-env: &n8n-env
EXECUTIONS_MODE: queue
DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_PORT: "5432"
DB_POSTGRESDB_DATABASE: n8n
DB_POSTGRESDB_USER: n8n
DB_POSTGRESDB_PASSWORD: ${DB_PASSWORD:?set in .env}
DB_POSTGRESDB_POOL_SIZE: "10"
QUEUE_BULL_REDIS_HOST: redis
QUEUE_BULL_REDIS_PORT: "6379"
N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY:?set in .env}
EXECUTIONS_DATA_PRUNE: "true"
EXECUTIONS_DATA_MAX_AGE: "336"
EXECUTIONS_DATA_SAVE_ON_SUCCESS: "none"
EXECUTIONS_DATA_SAVE_ON_ERROR: "all"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: n8n
POSTGRES_PASSWORD: ${DB_PASSWORD:?set in .env}
POSTGRES_DB: n8n
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
n8n-main:
image: n8nio/n8n:${N8N_VERSION:?pin a version}
environment: *n8n-env
ports:
- "5678:5678"
depends_on:
- postgres
- redis
n8n-worker:
image: n8nio/n8n:${N8N_VERSION:?pin a version}
environment: *n8n-env
command: worker
depends_on:
- postgres
- redis
volumes:
pgdata:
Notes on the choices inside it: pool size 10 matches the worked example above (4 workers would put you at 50 total connections); SAVE_ON_SUCCESS=none keeps the execution table small enough for the prune job to keep up; both secrets enter through .env, never inline. Scale by adding n8n-worker replicas — workers are stateless.
Failure Mode Playbook
| Symptom | Root cause | Fix |
|---|---|---|
SQLITE_BUSY: database is locked under load |
Single-writer lock vs. multi-process writes | Migrate to Postgres; WAL only delays this |
| Workers idle, executions "stuck" in queue | Pool exhaustion — writes queued behind 2 connections | Raise DB_POSTGRESDB_POOL_SIZE to match concurrency |
| Editor lags while executions run | Editor reads competing with writer queue | Postgres + WAL separation; or offload reads |
| Pruning never catches up | Prune is a writer competing on one lock (SQLite) | Postgres, plus SAVE_ON_SUCCESS=none to shrink input |
Connections exhausted at Postgres (max_connections) |
processes × pool > server budget | Lower pool, add workers instead, or raise max_connections |
| Credentials decrypt to garbage after scaling | Workers missing shared N8N_ENCRYPTION_KEY |
Set the identical key on every process |
The Pool Calculator
The formula inside: pool = clamp(concurrency, 2, 12); total = (workers + 1) × pool, counting the main process; safe ceiling = 80% of max_connections. Same math as the worked example above, by construction.
Frequently Asked Questions
Download this guide’s assets
Get the raw benchmark CSV + migration runbook from this guide. Instant download — no email required.
Download the bundle →Prefer it by email? Subscribe for the full library →
Does n8n queue mode work with SQLite at all?
SQLITE_BUSY: database is locked errors under sustained load.What is the default DB_POSTGRESDB_POOL_SIZE and why does it matter?
How do I move an existing n8n instance from SQLite to PostgreSQL?
n8n export:workflow --all and n8n export:credentials --all --decrypted, stand up a PostgreSQL 13+ instance, set DB_TYPE=postgresdb plus the DB_POSTGRESDB_* variables, start n8n so it creates the schema, then n8n import:workflow --separate and n8n import:credentials --separate. Execution history is not migrated; the decrypted credentials export is plaintext secrets and must be secured and deleted after import.Is WAL mode enough to keep SQLite in queue mode?
DB_SQLITE_POOL_SIZE > 0) lets readers proceed while a writer holds the lock, which helps the editor. Writers still serialize one-at-a-time — and in queue mode the writers are the workload. WAL moves the failure point to higher load; it does not remove the single-writer constraint. NFS-hosted SQLite files can break WAL entirely.How many executions per day before I must switch to PostgreSQL?
Do I also need to change how binary data is stored in queue mode?
N8N_DEFAULT_BINARY_DATA_MODE=s3.Where This Fits in the Scaling Path
This article is the database leg of the queue-mode decision. The full stack — container layout, worker counts by RAM, and the webhook response relay sizing that pairs with it — is covered in the queue mode docker-compose blueprint. When you are ready to pick hardware for the workers themselves, the VPS provider benchmark below runs the same staging-lab method against three clouds.
Related Cluster Intelligence
- Voiceflow vs Bland.ai: Voice Agent Comparison →
- ElevenLabs Pricing 2026: Plans, Credits, Alternatives →
- Serverless vs VPS in 2026: The Hard-Data Cost & Latency Blueprint →
- n8n vs Temporal vs Windmill: The Orchestration Decision →
- n8n Queue Mode Docker Compose & Redis Setup →
- n8n Webhook Response Relay: Breaking the 64 MiB Limit →
/links/[tool]). If you deploy through them, we may earn an affiliate commission at $0 added cost to you. All performance figures in this article come from internal testing under the configurations documented here; results vary by workload, scale, and environment. We disclose limitations and failure modes wherever they exist. Authored by Alex, Principal AI Infrastructure Architect at Wenboom. See our Terms of Service for the full disclaimer.