How big is your database.sqlite right now? If you don't know, docker compose exec n8n ls -lh /home/node/.n8n/database.sqlite answers in a second, and the number decides more of this comparison than any benchmark does. Under 100 MB on a single n8n instance, SQLite is doing its job and you can stop reading. Past a few hundred, or the moment you plan queue mode, PostgreSQL is where you're heading and the second half of this article is the move. I run Postgres for LumaDock's own instance and I still think SQLite gets more grief than it earns.
What n8n stores in the database
Users and roles, workflows with their version history and tags, credentials (encrypted with N8N_ENCRYPTION_KEY), execution records and their payloads, installed community packages, settings and license state, registered webhooks, variables and the migration log. Binary data is separate: files pass through the filesystem by default in regular mode and the database only in queue mode, unless you have a Business or Enterprise key for S3. The database engine underneath is SQLite or PostgreSQL. MySQL and MariaDB were deprecated in 1.0 and removed in 2.0, and the 2.0 breaking changes page is explicit that the MySQL node itself is unaffected. Only n8n's own storage lost them. If you still run a MySQL-backed 1.x instance, the migration section below is the same procedure.
SQLite in n8n 2.x
SQLite is the default when DB_TYPE is unset. The file sits at ~/.n8n/database.sqlite, which in Docker means inside the n8n_data volume next to the encryption key. Since 2.0 there is exactly one SQLite driver, the pooled one, which runs the database in WAL mode; the legacy driver was removed and n8n quotes "up to 10x faster" for the pooled driver over it. DB_SQLITE_POOL_SIZE is the connection pool for that driver and the breaking-changes page gives its new default as 2 (the older reference table still says 0, which was the legacy driver's value). DB_SQLITE_VACUUM_ON_STARTUP defaults to false.
That last variable matters because of how SQLite reclaims space. When execution pruning deletes rows, the file doesn't shrink; the freed pages get reused by later writes. A database.sqlite that reached 2 GB during a bad week stays 2 GB until a VACUUM rewrites it, either by setting the startup variable and restarting or by running it manually against the file. Postgres has the same property per table but autovacuum keeps it in check without you doing anything.
Something that surprised me when the database docs were restructured this year: n8n Cloud's Starter and Pro plans run on SQLite. Only the Enterprise Scaling plans get Postgres. So the engine that people on the forum call unfit for production is what n8n itself sells to thousands of paying customers, on single instances, with pruning turned on. The real limit is that one process owns the file, so the moment you want a second process writing executions, SQLite stops being an option.
PostgreSQL in n8n 2.x
n8n supports the two newest maintained major versions, 17 and 18 as of July 2026, plus 16 for compatibility. It asks you to stay on the latest minor within your major. The range moves every November when the Postgres project retires its oldest branch. The database selection page also rules out the lookalikes: Amazon Aurora is experimental, and AlloyDB, CockroachDB and YugabyteDB are unsupported. I pin postgres:17 in compose and will move to 18 once 16 drops out of the supported list, which is a year of doing nothing.
The connection is configured with DB_TYPE=postgresdb plus DB_POSTGRESDB_HOST (default localhost), DB_POSTGRESDB_PORT (5432), DB_POSTGRESDB_DATABASE (n8n), DB_POSTGRESDB_USER (postgres), DB_POSTGRESDB_PASSWORD and DB_POSTGRESDB_SCHEMA (public). DB_POSTGRESDB_POOL_SIZE defaults to 2 connections and is the first thing to raise when a main process plus workers start waiting on the pool; each n8n process holds its own pool, so the total against Postgres is pool size times process count. For a database on another server there's DB_POSTGRESDB_SSL_ENABLED, DB_POSTGRESDB_SSL_CA, DB_POSTGRESDB_SSL_CERT, DB_POSTGRESDB_SSL_KEY and DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED (default true).
Queue mode requires it. The queue mode docs say running with EXECUTIONS_MODE=queue on SQLite "isn't recommended", and the n8n queue mode guide assumes Postgres from the first line for that reason. In practice the main process and each worker all write execution rows, which is exactly the multi-writer case SQLite's file lock exists to prevent, and the pool size arithmetic above applies to every worker you add.
When to switch from SQLite to PostgreSQL
Four triggers, any one of them is enough. Queue mode, for the reason above. A second n8n process of any kind touching the same data. A database.sqlite that keeps growing after pruning is tuned, which on our instance meant a few hundred megabytes of executions before the editor's execution list started taking a second or two to load; that was the point I moved LumaDock's instance across, and I haven't measured SQLite past that size since. And retention: if you need execution history kept for months (audit, compliance, debugging a monthly billing run), that history lives comfortably in Postgres and awkwardly in a single file you have to copy whole.
The counter-argument is real. Postgres is another container to update, another volume to back up and roughly 100 to 200 MB of RAM idle. On a 2 GB VPS running one instance with a dozen workflows and 7 day pruning, that's overhead with no benefit, and the SQLite install is the one I'd recommend to a friend who asked.
Migrate n8n from SQLite to PostgreSQL
There is no in-place conversion. The path that works is: export workflows and credentials from the SQLite instance with the n8n CLI, bring up a fresh instance on Postgres with the same encryption key, import. Execution history doesn't come along in that route, which for most people is fine; if you need it, export:entities and import:entities exist for a whole-database move and I'd test those on a copy first, because how long they take with a few hundred thousand execution rows is something I couldn't tell you.
Export from the running SQLite instance. The --backup flag writes one JSON file per item into the directory, and the credentials export stays encrypted with your current key:
docker compose exec -u node n8n n8n export:workflow --backup --output=/home/node/.n8n/migrate/workflows/
docker compose exec -u node n8n n8n export:credentials --backup --output=/home/node/.n8n/migrate/credentials/
Confirm the key you're carrying over. If you never set N8N_ENCRYPTION_KEY, it's in the config file inside the volume:
docker compose exec -u node n8n cat /home/node/.n8n/config
Now stop the stack, add a postgres:17 service to compose, switch the n8n service to DB_TYPE=postgresdb with the DB_POSTGRESDB_* variables pointing at it, and put that same key into N8N_ENCRYPTION_KEY. Keep the n8n_data volume mounted, it still holds the exports, the key file and any binary data. The database block from the n8n Docker install for Ubuntu 24.04 drops into an existing project as is, healthcheck included. The depends_on condition there is what stops n8n from starting before Postgres accepts connections on the first boot.
Start it, open the editor and complete the owner setup on the empty Postgres instance. Then import, in this order, credentials first so workflows can resolve them:
docker compose exec -u node n8n n8n import:credentials --separate --input=/home/node/.n8n/migrate/credentials/
docker compose exec -u node n8n n8n import:workflow --separate --input=/home/node/.n8n/migrate/workflows/
Imported workflows arrive unpublished. Open each one and publish it or use n8n publish:workflow --id=<ID> per workflow (there is no --all for publish). Test webhooks re-register on publish, so providers pointing at your production webhook URLs need no change. Once the execution list fills up and a credential-backed node runs successfully, delete the migrate folder; it contains encrypted credentials and you don't want it in the next backup.
If the credentials import throws decryption errors, the key differs. Nothing else causes that.
Backup differences between SQLite and Postgres
SQLite backup is a file copy. With WAL mode there are three files while n8n runs (database.sqlite, -wal and -shm), so either stop the container and copy all three, or run sqlite3 on the host against the volume path with the .backup command, which uses SQLite's online backup API and produces a consistent single file. Copying only the main file while n8n is writing gives you a database that opens and is missing the last minutes.
Postgres backup is pg_dump from the container, restore is psql or pg_restore into an empty database:
docker compose exec -T postgres pg_dump -U n8n -d n8n -Fc > n8n-$(date +%F).dump
Either way the encryption key is part of the backup set or the credentials in the dump are noise. The cron script and retention are in the n8n backup and disaster recovery guide, along with a restore drill I'd run at least once before trusting either format.
Side by side
| SQLite | PostgreSQL | |
|---|---|---|
| Default | Yes (DB_TYPE=sqlite) | No (DB_TYPE=postgresdb) |
| Versions | Bundled, pooled WAL driver only since 2.0 | 16, 17, 18 (as of July 2026) |
| Queue mode | Not recommended | Required |
| Extra service | None | One container, 100 to 200 MB RAM idle |
| Space after pruning | Reused, not released (VACUUM) | Autovacuum |
| Backup | File copy or .backup | pg_dump |
| Where n8n Cloud uses it | Starter, Pro | Enterprise Scaling |

