If you self-host n8n, there is a good chance the instance that felt instant on day one now takes a few seconds to load the executions list, the Postgres volume keeps creeping toward its limit, and the occasional workflow that moves a large file makes the whole process spike. Nothing is “broken” — n8n is doing exactly what you told it to. The slowdown is almost always the same three things compounding: execution history piling up in Postgres, binary payloads being stored inside that same database, and dead rows that pruning leaves behind but never reclaims.
This guide walks through the fix as a repeatable playbook: audit what is actually taking up space, turn on and tune execution-data pruning, move binary data out of the database, and reclaim the disk Postgres is holding onto. Every step includes the exact environment variables and SQL you can paste in. The numbers below come from one of the mid-traffic n8n instances we run for these tutorials — roughly 45,000 executions/week across 60 active workflows on a single Hetzner CPX31 box in queue mode.
Why a healthy n8n gets slow
n8n persists every execution it runs. By default it saves both successful and failed runs, and it stores them in two Postgres tables: execution_entity (the metadata) and execution_data (the actual node input/output JSON). If you leave the defaults untouched, those tables grow forever. The executions list query, the pruning logic, and even some workflow lookups get slower as the row count climbs into the millions.
Binary data makes it worse. In the default binary mode, files that flow through your workflow — a PDF a webhook received, an image you generated, a CSV you fetched — get base64-encoded into that same execution_data table. A single 10 MB attachment saved on every run of a busy workflow turns into gigabytes of database bloat within days.
And here is the part that surprises people: even after you enable pruning, your Postgres volume often does not shrink. Deleting rows in Postgres marks them dead but leaves the space allocated for reuse. Without a VACUUM (and sometimes a VACUUM FULL), the disk footprint stays high. So the problem has three layers, and you have to address all three.
Step 1 — Audit before you change anything
You cannot fix what you have not measured. Connect to your n8n Postgres database and check which tables are actually large:
SELECT relname AS table,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows
FROM pg_catalog.pg_statio_user_tables t
JOIN pg_stat_user_tables s USING (relid)
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;
On our instance before tuning, this returned an execution_data table of 18 GB with 2.6M live rows and 900K dead rows, and an execution_entity table of 3.1 GB. That single query tells you whether your problem is row count (pruning config), binary payloads (binary mode), or dead tuples (vacuum) — usually it is all three.
Step 2 — Turn on and tune execution-data pruning
n8n has built-in pruning; it is just conservative by default. These are the environment variables that control it. Add them to your docker-compose.yml environment block (or your Kubernetes config map) and restart:
# Enable pruning of old executions
EXECUTIONS_DATA_PRUNE=true
# Delete executions older than this many hours (336 = 14 days)
EXECUTIONS_DATA_MAX_AGE=336
# Also cap the absolute number of stored executions
EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000
# How long soft-deleted rows wait before hard delete (hours)
EXECUTIONS_DATA_HARD_DELETE_BUFFER=1
The bigger win for most instances is to stop saving executions you will never look at. If a workflow succeeds thousands of times a day and you only ever debug the failures, do not persist the successes:
# Do not store successful production runs
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
# Keep failures so you can debug them
EXECUTIONS_DATA_SAVE_ON_ERROR=all
# Don't save per-node progress (expensive, rarely needed in prod)
EXECUTIONS_DATA_SAVE_ON_PROGRESS=false
# Don't persist manual editor test runs
EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=false
Setting EXECUTIONS_DATA_SAVE_ON_SUCCESS=none was the single highest-impact change we made: it cut new write volume to the executions tables by about 88%, because on this instance the overwhelming majority of runs succeed. If you rely on successful-run history for auditing, keep it on but lean harder on MAX_AGE instead. In queue mode, remember that the main process runs pruning, so these variables must be present on the main instance, not just the workers — the same operational nuance covered in our guide to running n8n in queue mode with Docker, Redis and Postgres.
Step 3 — Get binary data out of the database
By default n8n keeps binary data in memory and can serialize it into the execution data. For any workflow that touches files, switch the storage mode to the filesystem (or S3 on newer versions):
# Store binary data on disk instead of in the DB/memory
N8N_DEFAULT_BINARY_DATA_MODE=filesystem
# Where to put it (mount a volume here in Docker)
N8N_BINARY_DATA_STORAGE_PATH=/home/node/.n8n/binaryData
In Docker, mount that path to a named volume so it survives container restarts. If you are on n8n’s newer releases and have object storage available, N8N_DEFAULT_BINARY_DATA_MODE=s3 plus the S3 credentials keeps your application nodes stateless — which matters a lot if you scale workers horizontally. Either way, the goal is the same: your Postgres database should store references to binary data, never the bytes themselves.
One caveat: switching modes only affects new executions. Existing binary blobs already in the database will be cleared as their parent executions are pruned, so Step 2 and Step 3 work together over the following days.
Step 4 — Reclaim the disk Postgres is holding
After pruning deletes millions of rows, run a vacuum so Postgres can reuse or release that space. For routine maintenance, a standard vacuum with analyze is enough and does not lock the table:
VACUUM (VERBOSE, ANALYZE) execution_data;
VACUUM (VERBOSE, ANALYZE) execution_entity;
Standard VACUUM makes space reusable but usually does not return it to the operating system. If your volume is dangerously full and you need the disk back, VACUUM FULL rewrites the table and releases the space — but it takes an exclusive lock, so schedule it during a maintenance window:
-- Locks the table; run during a quiet window
VACUUM FULL execution_data;
For a zero-downtime alternative on large tables, pg_repack reclaims space without the long exclusive lock. Finally, because a busy n8n generates constant churn, tune autovacuum to be more aggressive on these two tables so you rarely need manual intervention:
ALTER TABLE execution_data SET (autovacuum_vacuum_scale_factor = 0.02);
ALTER TABLE execution_entity SET (autovacuum_vacuum_scale_factor = 0.02);
Step 5 — Verify with the same query
Re-run the audit query from Step 1 a day or two after the changes propagate. On our instance the results looked like this:
| Metric | Before | After (7 days) |
|---|---|---|
| execution_data size | 18 GB | 1.4 GB |
| execution_entity size | 3.1 GB | 0.4 GB |
| Executions list load time | 4.2 s | 0.5 s |
| Postgres volume used | 24 GB | 3 GB |
The executions UI became responsive again, the disk-space alert stopped firing, and the CPU spikes on file-heavy workflows disappeared once the binary bytes left the database. None of this required a bigger server — just correct configuration.
Takeaways
Self-hosted n8n slowdowns are a configuration problem, not a capacity problem, until proven otherwise. Measure first with a single Postgres query, cap and prune execution history (and stop saving runs you will never read), push binary data to the filesystem or object storage, and vacuum so Postgres actually releases the space. Wire these settings into your infrastructure-as-code so a fresh deploy is fast from day one — and pair them with real observability so you catch the next bottleneck early. Our walkthrough on monitoring n8n with Prometheus and Grafana shows how to alert on execution-table growth before it becomes a page.
Want a working n8n config you can copy? Bookmark n8nfuel and subscribe for weekly n8n recipes — real workflow JSON, production configs, and measured results, not “what is n8n” intros. And if you are hardening a self-hosted deployment end to end, read our companion guide on credentials and secrets management in production next.
Frequently asked questions
Does enabling pruning delete my workflows?
No. Pruning only removes execution history from the execution_entity and execution_data tables. Your workflows, credentials, and settings live in separate tables and are never touched by EXECUTIONS_DATA_PRUNE.
Why is my Postgres volume still huge after pruning?
Because deleting rows in Postgres marks them dead but does not automatically return the disk to the OS. Run VACUUM for routine cleanup, or VACUUM FULL / pg_repack when you need to physically reclaim the space. Tuning autovacuum_vacuum_scale_factor lower keeps it from building up again.
Is it safe to set EXECUTIONS_DATA_SAVE_ON_SUCCESS to none?
For most production workloads, yes — you keep failures for debugging and drop the successful noise. The trade-off is that you lose successful-run history for auditing. If you need that history, leave it on and control growth with EXECUTIONS_DATA_MAX_AGE and EXECUTIONS_DATA_PRUNE_MAX_COUNT instead.
Should I use filesystem or S3 binary data mode?
Use filesystem for a single-node or shared-volume setup — it is simplest and removes binary bloat from Postgres immediately. Choose s3 when you run multiple workers and want them stateless, since every worker can reach the same object store without a shared disk. Both keep the bytes out of your database.