postgres://internals

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.

fig.01 · dead tuples & vacuum1 slot = 1 tuple version
live
6
dead
0
reclaimable
0
held back
0
file slots
6
bloat
0%
1
2
3
4
5
6

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:

sql · live postgresrun me first — then watch the size
⌘/ctrl + enter

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:

sql · live postgreseditable — run it
⌘/ctrl + enter

Now check the size:

sql · live postgreseditable — run it
⌘/ctrl + enter

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:

sql · live postgresa second round of churn reuses the freed space — flat
⌘/ctrl + enter

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):

sql · live postgreseditable — run it
⌘/ctrl + enter
sql · live postgreseditable — run it
⌘/ctrl + enter

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":

sql · live postgreseditable — run it
⌘/ctrl + enter

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.

// challengewrite it yourself

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.)

⌘/ctrl + enter
// challengewrite it yourself

Bloat is dead versions, not extra rows. Show that churn still has exactly 5000 live rows.

⌘/ctrl + enter

// 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

nextLocks & Deadlocks