VACUUM & Autovacuum
Bloat, freezing, and dead tuple cleanup
MVCC makes concurrent reads and writes safe by never
overwriting a row in place: an UPDATE writes a brand-new tuple and
marks the old one dead (xmax set) rather than mutating it, and a
DELETE just marks a tuple dead without removing it. Nobody reclaims
that space automatically as part of the write — that's VACUUM's
entire job, and if it falls behind, the table just keeps growing.
Dead tuples and why they pile up
UPDATE accounts SET balance = balance - 50 WHERE id = 1;That single statement doesn't touch the old row at all — it inserts a
new tuple with the new balance and sets xmax on the old tuple to the
current transaction ID. The old tuple stays on its page, fully intact,
invisible to any transaction that starts after this one commits, but
still physically present and still costing space and I/O until
something removes it.
What VACUUM actually does
VACUUM accounts;
VACUUM ANALYZE accounts; -- also refreshes planner statistics
VACUUM VERBOSE accounts; -- prints what it found and reclaimedPlain VACUUM scans the table, identifies tuples that are dead to
every current and future transaction (no open snapshot can still see
them), and marks that space reusable by recording it in the table's
free space map (from
TOAST, FSM & Visibility Map). Crucially, it does
not shrink the file on disk — freed space is only made available
for future inserts and updates to reuse. A table that had a huge
DELETE and then a plain VACUUM will occupy exactly the same number
of bytes on disk as before, just with more of those bytes marked free
internally.
VACUUM runs concurrently with normal reads and writes — it only
needs a lightweight lock, never blocking queries against the table.
VACUUM FULL: the exclusive-lock alternative
VACUUM FULL accounts;VACUUM FULL actually shrinks the file: it rewrites the entire table
into a new file containing only live tuples, then swaps it in and
drops the old one — the same technique as CLUSTER. That's the only
way to hand disk space back to the operating system after a huge
delete. The cost is an ACCESS EXCLUSIVE lock for the whole
operation, blocking every read and write against the table until it
finishes.
VACUUM FULL on a large table can lock it for minutes or hours.
It's a maintenance-window operation, not something to run reflexively
because pg_relation_size looks bigger than expected — plain
VACUUM (or just waiting for autovacuum) is almost always the right
first move, since disk space that's marked reusable gets consumed by
future writes anyway.
Autovacuum
In practice you rarely run VACUUM by hand — the autovacuum
daemon does it automatically, per table, based on how many rows have
changed since the last run:
SHOW autovacuum; -- on by default
SHOW autovacuum_vacuum_scale_factor; -- default 0.2 (20% of table)
SHOW autovacuum_vacuum_threshold; -- default 50 rows
-- trigger point, roughly:
-- dead tuples > autovacuum_vacuum_threshold
-- + autovacuum_vacuum_scale_factor * total rowsFor a 10-million-row table, the default scale factor means autovacuum waits until roughly 2 million rows are dead before it bothers — fine for a slowly-changing table, potentially a real problem for a small, extremely hot one.
The single most common autovacuum production incident: a small,
high-churn table (a queue, a session table, a counters table) gets
updated thousands of times a minute, and the default scale-factor
threshold means autovacuum only fires occasionally relative to how
fast dead tuples accumulate. Meanwhile every query against the table
has to scan past a growing number of dead tuples, index bloat grows
alongside it, and the table's disk footprint climbs even though its
logical row count never changes. The fix is a per-table override —
lower thresholds for hot tables specifically, e.g. ALTER TABLE queue_jobs SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_delay = 2). Watch
pg_stat_user_tables.n_dead_tup and last_autovacuum for any table
under heavy write load — don't wait for query latency to surface the
problem first.
Transaction ID wraparound and VACUUM FREEZE
Postgres transaction IDs (xid) are a 32-bit counter, and MVCC
visibility depends on comparing xids to decide what's older or
newer. A 32-bit counter wraps around eventually, and if an old tuple's
xmin were left as a raw comparable number forever, wraparound would
make old committed rows suddenly look like they came from the future
— catastrophic silent data-visibility corruption.
VACUUM prevents this by freezing old tuples: once a tuple is old
enough that every current and future transaction is guaranteed to see
it as committed, its xmin is replaced with a special
frozen marker that's always considered "in the past," permanently,
regardless of counter wraparound.
VACUUM FREEZE accounts; -- freeze eligible tuples now, not on autovacuum's schedule
SELECT datname, age(datfrozenxid) FROM pg_database; -- how close to wraparoundIf autovacuum is disabled, misconfigured, or simply can't keep up on
a large enough table, age(datfrozenxid) climbs toward the
wraparound limit. Postgres protects itself by refusing new writes
cluster-wide once it gets dangerously close — a full outage that can
only be fixed by running VACUUM FREEZE (often taking the affected
tables offline for a while to catch up). This is one of the few
Postgres failure modes that's entirely preventable and entirely
self-inflicted: monitor transaction age, don't disable autovacuum,
and don't let it starve on a busy table.
Next: even with vacuum keeping the live table lean, you still need a plan for disasters vacuum can't help with, in Backup & Restore.