If you've run PostgreSQL in production long enough, you've eventually
looked at pg_stat_user_tables, seen a dead_tup count in the millions,
and wondered what's going on. The answer is almost always: not enough
vacuuming.
Why Postgres needs vacuuming at all
Postgres uses a technique called MVCC (Multi-Version Concurrency Control)
to let multiple transactions read and write the same table without
blocking each other. Instead of updating a row in place, an UPDATE
creates a brand new version of the row and marks the old one as no longer
current. A DELETE doesn't actually remove the row either — it just marks
it as dead.
This is great for concurrency: readers never block writers, and writers
never block readers. But it means old row versions pile up on disk. Those
are called dead tuples, and something has to go clean them up.
That something is VACUUM.
What VACUUM actually does
Running VACUUM on a table:
- Scans the table for dead tuples and marks the space they occupied as
reusable by future inserts and updates (plain
VACUUMdoes not return that space to the operating system — it just makes it available for Postgres to reuse within the table). - Updates the visibility map, which lets Postgres skip pages that contain only live, all-visible rows during future scans — a meaningful speedup for both vacuum itself and index-only scans.
- Updates planner statistics if you run
VACUUM ANALYZE. - Prevents transaction ID wraparound, a much scarier problem where Postgres's internal transaction counter could theoretically wrap around and make old rows look like they're from the future. Vacuum freezes old rows to prevent this from ever becoming a real risk.
If you actually want the disk space back, you need VACUUM FULL, which
rewrites the entire table into a new file — but it takes an exclusive lock
on the table for the duration, so it's not something to run casually on a
busy production table.
Autovacuum: the part you shouldn't turn off
Postgres ships with a background process, autovacuum, that watches your tables and triggers a vacuum automatically once a table has enough dead tuples. The default thresholds are conservative:
autovacuum_vacuum_threshold = 50
autovacuum_vacuum_scale_factor = 0.2
In practice this means: vacuum a table once roughly 20% of it, plus 50
rows, are dead. That default is tuned for small-to-medium tables. On a
large, high-churn table — think a queue table or a table with a hot
updated_at column — 20% of a 50-million-row table is 10 million dead
rows before autovacuum even fires, which is often already too much.
Common tuning moves for busy tables:
- Lower
autovacuum_vacuum_scale_factor(or set a table-specific override withALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.05)) so vacuum triggers sooner. - Increase
autovacuum_vacuum_cost_limit/ decreaseautovacuum_vacuum_cost_delayso each vacuum run does more work per cycle, instead of throttling itself into uselessness on a busy table. - Bump
autovacuum_max_workersif you have many tables that all need attention at once and they're competing for the same worker slots.
A quick way to check if you're behind
select relname, n_dead_tup, n_live_tup,
round(n_dead_tup::numeric / greatest(n_live_tup, 1), 4) as dead_ratio,
last_autovacuum
from pg_stat_user_tables
order by n_dead_tup desc
limit 20;
If you see tables with a high dead ratio and a last_autovacuum that's
suspiciously old (or null), that table's autovacuum settings probably need
tuning — or something is actively holding a long-running transaction open
and preventing vacuum from cleaning up rows that are technically dead but
still "visible" to that old transaction.
The single most common real-world cause of vacuum falling behind isn't bad
settings — it's a long-idle transaction (an app connection left open in a
transaction, or a forgotten BEGIN in a psql session) that pins the
oldest-needed row version in place indefinitely. Checking
pg_stat_activity for old transactions is usually step one before touching
any autovacuum knob.