VACUUM and table bloat
MVCC leaves dead tuples behind every UPDATE and DELETE; autovacuum reclaims them. When it falls behind, bloat inflates every scan — and VACUUM FULL's exclusive lock is the wrong cure in production.
A queue table — rows inserted, processed, deleted, thousands per minute — has 4,000 live rows at any moment. Its on-disk size is 9 GB. A SELECT count(*) that should be instant takes 6 seconds because the table is 99.9% dead tuples the planner still has to scan past. Someone, in a 2 a.m. panic, runs VACUUM FULL and locks the table for 11 minutes mid-incident. Both the bloat and the cure were avoidable. This lesson is why MVCC creates the garbage and how to collect it without taking an outage.
Where dead tuples come from
Why does a table grow to 9 GB when it holds only 4,000 live rows? The mechanism is built into every write you do. The databases track’s MVCC chapter derives this in depth; the SQL author needs the consequence. Under MVCC, Postgres never overwrites a row in place. An UPDATE writes a new row version and marks the old one dead; a DELETE just marks the row dead. The old version lingers because some still-running transaction’s snapshot might need to see it. So every UPDATE and DELETE produces a dead tuple — a row that’s invisible to new queries but still physically occupies a page on disk.
Until those dead tuples are cleaned, they cost you twice: they take space, and every sequential or index scan still has to read the pages they sit on and skip them. A table that’s 90% dead tuples does roughly 10× the I/O for the same live data.
-- See the damage on any table:
SELECT relname, n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
WHERE relname IN ('orders', 'events', 'line_items')
ORDER BY n_dead_tup DESC;Autovacuum: what triggers it
autovacuum is a background process that runs VACUUM automatically. It does not shrink the file — it marks dead-tuple space as reusable so new rows fill the holes instead of extending the table. It also runs the ANALYZE that keeps statistics fresh (lesson 03) and updates the visibility map.
It triggers per table when:
n_dead_tup > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × n_live_tupDefaults: threshold = 50, scale_factor = 0.2. So a table autovacuums when dead tuples exceed 20% of live rows. That 0.2 is fine for a 10k-row table (vacuum at 2k dead) but terrible for a 100M-row table — it waits for 20M dead tuples, by which time scans are already bloated. The production rule: lower scale_factor per large/hot table, e.g. ALTER TABLE events SET (autovacuum_vacuum_scale_factor = 0.02); so it vacuums at 2% churn.
The visibility map and index-only scans
VACUUM maintains the visibility map: a bitmap marking pages where every tuple is visible to all transactions. This is what makes an index-only scan possible — if the page is all-visible, Postgres can answer from the index alone without a heap fetch to check visibility (the databases indexes chapter goes deep). A bloated, under-vacuumed table has a stale visibility map, so “index-only” scans silently degrade into heap fetches. Vacuuming isn’t just garbage collection; it’s what keeps your fastest scan type fast.
VACUUM vs VACUUM FULL — and the prod trap
VACUUM(plain) — reclaims dead space in place for reuse. Takes only aSHARE UPDATE EXCLUSIVElock; reads and writes continue. This is what autovacuum runs. It does not return disk to the OS, but it stops the bloat from growing.VACUUM FULL— rewrites the entire table into a new compact file, returning space to the OS. But it takes anACCESS EXCLUSIVElock for the whole rewrite — every read and write blocks until it finishes. On a large table that’s minutes of total outage. Never runVACUUM FULLon a live production table.
To actually reclaim disk on a bloated production table, use pg_repack (an extension): it rebuilds the table into a fresh copy online, taking the exclusive lock only for a brief final swap. Same compaction, no outage.
▸Common mistake
The classic incident: a long-running transaction (an idle BEGIN, a stuck analytics query, a forgotten psql session) holds back the xmin horizon — the oldest snapshot still in use. Autovacuum cannot reclaim any tuple newer than that horizon, on any table, because that ancient transaction might still need to see it. So a single forgotten idle in transaction connection can make bloat balloon database-wide while autovacuum runs constantly but reclaims nothing. Check SELECT * FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY xact_start; and set idle_in_transaction_session_timeout to kill them automatically. This is why “autovacuum is running but bloat keeps growing” is almost always a long-transaction problem, not an autovacuum-tuning problem.
A high-churn queue table holds 4,000 live rows but is 9 GB on disk and slow to scan. What is happening?
You need to reclaim disk from a badly bloated table on a live production system. Best tool?
Fill in the blank: plain VACUUM marks dead-tuple space as _______ for new rows but does not shrink the file; VACUUM FULL rewrites the file to return disk to the OS, at the cost of an exclusive lock.
- 01Why does every UPDATE and DELETE create a dead tuple, and what does that cost?
- 02What triggers autovacuum, and why is the default scale_factor a problem on large tables?
- 03How do VACUUM, VACUUM FULL, and pg_repack differ, and which is safe in production?
MVCC never overwrites a row, so every UPDATE and DELETE leaves a dead tuple that lingers on disk until vacuum reclaims it — and until then it bloats the table and forces every scan to read past it, easily 10× the I/O on a heavily-churned table. Autovacuum reclaims that space in place (not shrinking the file), refreshes statistics, and maintains the visibility map that keeps index-only scans fast; it fires at threshold + scale_factor × live_rows, and the 0.2 default scale factor is too lax for large tables — lower it per hot table. To reclaim actual disk, VACUUM won’t (it only frees space for reuse) and VACUUM FULL will but with an ACCESS EXCLUSIVE lock that means an outage — so use pg_repack to compact online. And when autovacuum runs yet bloat keeps growing, the cause is almost always a long-lived idle in transaction connection pinning the xmin horizon so nothing newer can be reclaimed database-wide. Monitor n_dead_tup and pg_stat_activity; tune before it becomes the 2 a.m. incident.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.