n8n 2.x prunes execution data on its own. The defaults are EXECUTIONS_DATA_PRUNE=true, EXECUTIONS_DATA_MAX_AGE=336 (hours, so 14 days) and EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000, and unless you changed one of those, your instance is already deleting anything older than two weeks or past the ten thousand most recent runs. The questions I get about clearing n8n execution history come from two groups: people who want a shorter window than that, and people with a 6 GB database who can't work out why pruning didn't shrink it. Both are covered below, along with the settings that decide how much of an audit trail survives.
Pruning defaults in n8n 2.x
These are the variables, with the values n8n uses when you set nothing:
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL=60
EXECUTIONS_DATA_HARD_DELETE_BUFFER=1
EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL=15
The full table sits on the executions environment variables page; the six above are the ones that decide pruning. Max age is in hours, the two intervals are in minutes and the buffer is in hours. A count of 0 means no count limit.
Deletion happens in two passes. Every 60 minutes n8n looks for executions that are older than the max age or beyond the max count and marks them as deleted (there's a deletedAt column on the executions table for this). They disappear from the executions list at that point but the rows are still there. After the one hour buffer, the hard delete pass, which runs every 15 minutes, removes the marked rows for good. So the shortest time between "too old" and "gone from disk" is a bit over an hour, and if you set EXECUTIONS_DATA_MAX_AGE=1 expecting an instant wipe, that's why it looks like nothing happened at first.
Three kinds of execution are never touched by pruning: anything in the new, running or waiting state plus any execution you annotated with a rating or a tag. That second one matters more than it sounds, I'll come back to it.
Change the retention window with EXECUTIONS_DATA_MAX_AGE
n8n's own execution data management page gives this example, which keeps a week of failed runs and drops successful ones entirely. Put the variables in the environment: block of your n8n service, and set the same values on workers if you run queue mode, so no container is working from a different idea of the rules:
services:
n8n:
image: n8nio/n8n:2.38.5
environment:
- EXECUTIONS_DATA_SAVE_ON_ERROR=all
- EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
- EXECUTIONS_DATA_SAVE_ON_PROGRESS=false
- EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS=false
- EXECUTIONS_DATA_PRUNE=true
- EXECUTIONS_DATA_MAX_AGE=168
- EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000
I run something looser than that. On our instance EXECUTIONS_DATA_SAVE_ON_SUCCESS stays at all with a 72 hour max age, because when a customer asks why an invoice reminder went out twice, I want to open the successful run and see the exact items that passed through, and three days covers the gap between a weekend and someone reading the ticket. Success data is the expensive part though. Our WHMCS sync alone produces around 2,000 executions a day, which means the default count cap of 10,000 would have started deleting at five days regardless of the age setting. Check which of the two limits bites first for your volume before you tune either.
EXECUTIONS_DATA_SAVE_ON_PROGRESS=false is the one I'd leave alone unless you rely on resuming a failed run from the node where it stopped. With it on, n8n writes to the database after every node, and on a busy workflow that write traffic shows up in Postgres before anything else does.
Per-workflow overrides in workflow settings
The environment values are the instance default. Each workflow can override them from its settings panel, where the options are named "Save failed production executions", "Save successful production executions", "Save manual executions" and "Save execution progress", each with a "Default" choice that inherits the env value. The pattern I use: instance default drops successes, and the four workflows that touch billing are switched to save everything. You get the audit trail where money moves and a small table everywhere else.
Why the database does not shrink after pruning
Pruning deletes rows. It does not give the space back to the operating system, and this is where the "I set max age to 24 hours and the disk is still full" tickets come from. What happens next depends on the database.
SQLite: VACUUM or DB_SQLITE_VACUUM_ON_STARTUP
SQLite keeps the freed pages inside database.sqlite and reuses them for new executions, so the file stops growing but never gets smaller. To reclaim the space you run a VACUUM, which rewrites the whole file. n8n can do it for you on boot:
DB_SQLITE_VACUUM_ON_STARTUP=true
Or do it once by hand with the container stopped, from the host, against the volume path:
docker compose stop n8n
sqlite3 /var/lib/docker/volumes/n8n_n8n_data/_data/database.sqlite "VACUUM;"
docker compose start n8n
The volume path differs if you named the project or the volume differently; docker volume inspect tells you the real one. VACUUM needs free disk equal to the size of the file while it runs, which is a nasty surprise on a VPS that's already at 95%. When I did this on an older instance the file went from 1.9 GB to 410 MB, and the executions list opened in under a second again instead of four or five. I only ran it on SQLite. How long a VACUUM takes on a multi-gigabyte file on slower storage I couldn't tell you, since NVMe made it a 20 second job for me. If your instance has grown to that point, the honest fix is moving n8n from SQLite to PostgreSQL instead of scheduling VACUUMs. A VACUUM buys you a few months at most.
Postgres: autovacuum and checking table sizes
Postgres autovacuum marks dead rows as reusable on its own, so the tables stop growing once pruning catches up, same as SQLite. Reclaiming the space for the OS needs VACUUM FULL, which takes an exclusive lock on the table for the duration, or pg_repack if you can't afford the lock. Before either, look at what's big:
SELECT relname,
pg_size_pretty(pg_total_relation_size(relid)) AS total
FROM pg_catalog.pg_statio_user_tables
WHERE relname IN ('execution_entity','execution_data','execution_metadata','execution_annotations')
ORDER BY pg_total_relation_size(relid) DESC;
execution_data is nearly always the one. It holds the JSON payload of every saved run plus a copy of the workflow as it was at execution time. execution_entity is the index of runs (status, timestamps, workflow id) and stays small by comparison. execution_metadata holds custom execution data and execution_annotations the ratings and tags. Run the query, prune, wait an hour, run it again. If execution_data is smaller in dead-tuple terms but the same on disk, that's autovacuum doing its job, and the Postgres docs on VACUUM spell out the difference between plain and FULL better than I can here. Then you decide if the OS-level space justifies a locked table. On a VPS with 40% disk free, it usually doesn't.
Do not delete from execution_entity by hand
Older guides (mine included, the 2025 version of this page had one) gave you a cron job that ran DELETE FROM execution_entity WHERE "startedAt" < .... Don't do that on a 2.x schema. The payload isn't in that table anymore; it lives in execution_data with a foreign key that cascades on delete, plus rows in the metadata and annotations tables, and there's the deletedAt soft-delete column that the pruning loop expects to manage itself. There's also a storedAt column now, because execution data can live outside the database when N8N_EXECUTION_DATA_STORAGE_MODE is set to filesystem or s3, and a SQL delete leaves those files orphaned with nothing left that knows they exist.
If you need a one-off purge, set EXECUTIONS_DATA_MAX_AGE to a small number, restart, wait for the two passes to run, then put your normal value back. Or select the runs in the executions list and delete them from the UI, which goes through the same code path. Both are slower than a SQL statement and both leave the schema consistent.
Keeping audit value after pruning
The retention window is a data minimisation control as much as a disk one, which is a point the GDPR notes for self-hosted n8n make at more length. Within that window, four things preserve what you'd want in an audit.
EXECUTIONS_DATA_SAVE_ON_ERROR=all stays on. Failures are rare relative to successes, so keeping every one of them costs almost nothing and they're the runs you'll be asked about.
Annotations are your pin. An execution with a tag or a rating is excluded from pruning for as long as the annotation exists, so when a run is evidence of something (a duplicate charge, a support case, a change you're testing), tag it and it survives every cycle. I didn't know this for the first year I used the feature and treated tags as decoration.
Insights keeps counts after the rows are gone. The summary banner (total runs, failures, failure rate, time saved) is available in every edition, and the dashboard with per-workflow breakdowns and longer date ranges needs a Business or Enterprise licence. One thing I haven't tested properly: what Insights does with its counters once the executions behind them are pruned. On our instance the totals stayed stable across pruning cycles, but I didn't sit down and verify it, so treat the counts as a trend and the execution rows as the record.
Log streaming, which pushes execution events to syslog, a webhook or Sentry as they happen, is Enterprise only. The community-edition version of the same idea is a scheduled workflow that uses the n8n node (Execution, Get Many) to copy the id, workflow, status and timing of each run into a table of your own, or into whatever warehouse you keep. The summary rows are a few hundred bytes each, so you can keep years of them while the payload data is pruned in days. A quick sanity check on that table from Grafana is a nice addition once you've got the Prometheus and Grafana setup for n8n running, since the metrics endpoint counts executions but doesn't keep a list. Ours is one stat panel: runs per day per workflow, nothing else.
Binary data pruning
Files that pass through a workflow (attachments, downloaded PDFs, generated images) are stored separately from the JSON payload, in N8N_BINARY_DATA_STORAGE_PATH for filesystem mode or in the database in database mode, and they're pruned on the same schedule as the executions they belong to. Pruning only runs against the mode that is currently active. If you switched from filesystem to database at some point, the old files under binaryData/ stay where they are until you delete them yourself, and that directory has a habit of being what fills a disk after everything else looks clean.
Backups and pruning pull in opposite directions, and the n8n backup and restore guide covers how to keep dumps small without throwing away what you need. The short version is: prune first, then dump.

