VACUUM & Bloat
// the problem
MVCC's superpower — lock-free reads — has a bill attached. Every UPDATE
writes a new row version and leaves the old one behind; every DELETE just
marks a row dead. Nothing is erased in place. So who cleans up the corpses, and
why does an idle transaction on the other side of the office make it impossible?
Meet VACUUM.
Churn the table below and watch dead tuples pile up. Then run VACUUM and see what it does — and what it doesn't. Open a long-running transaction and watch cleanup stall.
update/delete → dead tuples pile up (bloat) · VACUUM frees them for reuse (file stays big) · VACUUM FULL rewrites and shrinks
Two things to notice. First, VACUUM turns dead slots into reusable ones but
the file doesn't shrink — the space is handed back to the table for
future rows, not to the operating system. Second, when a long transaction is
open, dead tuples that died after it go held back: VACUUM can't touch
them, because that old transaction's snapshot might still need to see them.
See bloat for real
Let's bloat an actual table. Create it, note its size, then update every row once and measure again:
The file roughly doubled — 5,000 live rows plus 5,000 dead versions, all on disk. That's bloat: space that's allocated but holds nothing useful.
VACUUM reclaims space; it doesn't give it back
Run VACUUM on its own — it's a maintenance command that can't share a
statement with anything else:
Now check the size:
Same size. VACUUM found the 5,000 dead tuples and recorded their slots in
the table's free space map so future inserts and updates can reuse them — but
it left the file exactly as big as it was. That's the design, not a bug. Prove
why it's the right one: churn every row again and the file doesn't grow,
because the new versions drop straight into the space the last round freed:
So a healthy table isn't one with no dead tuples — it's one where vacuum reclaims them as fast as churn creates them, holding the live-to-dead ratio flat. Bloat only spirals when dead tuples pile up faster than vacuum can reclaim them.
VACUUM FULL: the one that actually shrinks
To truly return space to the operating system you need VACUUM FULL, which
rewrites the entire table into a fresh, compact file (again, on its own):
Back to its lean size — every dead version gone, the file rebuilt from scratch.
// gotcha · VACUUM FULL takes an exclusive lock
VACUUM FULL rewrites the table into a new file, which requires an
ACCESS EXCLUSIVE lock — nothing can read or write the table while it runs,
and it needs room for a second copy. On a large production table that's an
outage. Prefer letting plain (auto)vacuum keep bloat flat; reach for tools like
pg_repack when you truly must shrink online.
The cleanup horizon (and the long-transaction trap)
VACUUM can only remove a dead tuple once no running snapshot could still need
it. Recall the snapshot xmin from the MVCC lesson — the oldest transaction
still running. Postgres tracks the smallest xmin across every backend (each
one is visible as backend_xmin in pg_stat_activity), and that becomes the
cleanup horizon. A dead tuple is removable only if the transaction that
deleted it committed before that horizon; otherwise some still-open snapshot
might legitimately still see it.
Here's the trap: one long-open transaction pins the horizon in the past — and because its snapshot could need any row that has died since it began, it blocks cleanup across the whole instance (every database in the cluster), not just its own table. Dead tuples pile up everywhere while one connection sits idle.
// gotcha · idle-in-transaction is the classic bloat bomb
An app that opens a transaction and forgets to commit — an idle in transaction
connection — will silently block vacuuming everywhere. Bloat balloons, queries
slow down, and the cause is a connection doing nothing. Set
idle_in_transaction_session_timeout and watch pg_stat_activity for old
xact_start times.
autovacuum does this for you
You rarely run VACUUM by hand. autovacuum wakes up when a table has
accumulated enough dead tuples (by default, ~20% of the table) and vacuums it in
the background. Tuning it — making it run more often on hot tables, not less
— is one of the highest-impact things a Postgres operator does. A table that
outpaces autovacuum is the usual story behind mysterious, ever-growing bloat.
Freezing and transaction-id wraparound
VACUUM has a second, unrelated job. Remember from MVCC that visibility compares transaction ids — but ids are only 32 bits (~4.2 billion), and Postgres treats the id space as a circle: for any transaction, roughly half of it is "in the past" and half "in the future." That works until an id gets old enough that ~2 billion newer transactions have happened — at which point it would flip from looking past to looking future, and a row you inserted years ago would suddenly become invisible. Catastrophe.
The fix is freezing: VACUUM marks very old, still-live tuples as frozen — a
flag in the tuple header that means "committed and visible to everyone, forever,"
so their actual xmin stops mattering and can safely be recycled. Every table
tracks how far behind it is with relfrozenxid; age() turns that into "how many
transactions since this table was fully frozen":
Autovacuum launches a freeze pass automatically as that age approaches
autovacuum_freeze_max_age (200 million by default). If freezing somehow falls
~2 billion behind — usually because autovacuum was disabled or perpetually
blocked — Postgres protects your data by refusing new writes until you vacuum.
It's rare, but it's the reason "just turn autovacuum off, it's slowing us down" is
a career-limiting move.
// note · HOT updates avoid some of this
Recall from the B-tree lesson: if an UPDATE changes no indexed column and the
new version fits on the same page, Postgres does a Heap-Only Tuple update — no
index churn, and the dead version can be cleaned by lightweight HOT pruning
without a full VACUUM. A good fillfactor leaves room for it.
Your turn
The churn table (from the first cell) has been bloated by heavy updates.
Report churn's 'freeze age' — how many transactions have happened since the table was last fully frozen. (This is the number autovacuum watches to prevent xid wraparound.)
Bloat is dead versions, not extra rows. Show that churn still has exactly 5000 live rows.
// what you now understand
- 01Every UPDATE/DELETE leaves a dead tuple; MVCC never overwrites in place, so dead versions accumulate as bloat (5,000 rows updated once ≈ doubled the file).
- 02VACUUM records dead slots in the free space map for reuse but does NOT shrink the file. A second round of churn reuses that space, so the file plateaus — healthy bloat is a stable live:dead ratio, not zero dead tuples.
- 03VACUUM FULL rewrites the table into a fresh compact file (back to lean size), but takes an ACCESS EXCLUSIVE lock — an outage on big tables; prefer pg_repack online.
- 04VACUUM can only remove a dead tuple older than the cleanup horizon = the smallest backend_xmin across all sessions. One long-open / idle-in-transaction session pins it and blocks cleanup instance-wide.
- 05autovacuum runs vacuuming automatically on dead-tuple thresholds — tuning it to keep up is a core operational skill.
- 06VACUUM also freezes old still-live rows: the 32-bit xid space is circular, so an unfrozen id ~2 billion transactions old would flip to 'future' and vanish. age(relfrozenxid) tracks the lag; autovacuum freezes near 200M, and Postgres refuses writes if freezing falls ~2B behind.
// self-test
You run VACUUM on a badly bloated table, but `pg_relation_size` doesn't drop. Is something broken?
// self-test
Your database's bloat is growing and autovacuum seems to do nothing, even on tables being vacuumed. What should you check first?
// go deeper
- Routine Vacuuming (official docs) — dead tuples, autovacuum, freezing, wraparound
- VACUUM command — VACUUM, VACUUM FULL, FREEZE, ANALYZE options
- Autovacuum configuration — thresholds and tuning knobs
- vacuumlazy.c — the lazy-vacuum implementation in the source