PostgreSQLVIII · VACUUM, Autovacuum and WraparoundVacuum
Measuring bloat honestly
What you'll learn
- Distinguish dead tuples from bloat, and measure each with the right tool
- Decide whether observed free space is a problem or a working set
- State precisely what VACUUM FULL costs, including its disk requirement
- Choose between doing nothing, an online rewrite and VACUUM FULL
Prerequisites
Practice
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
Bloat is discussed more than it is measured, and the number most people cite for it is measuring something else.
Dead tuples are not bloat
n_dead_tup counts versions vacuum has not yet reclaimed. It measures
outstanding work.
Bloat is space inside the relation that holds no live data. It measures what the table is costing you.
The two are related and they are not the same, which the following pair of measurements makes unarguable. Both tables have had every dead tuple successfully removed by vacuum.
$ psql -U postgres -x -c "SELECT * FROM pgstattuple('vac_scatter')"table_len | 48193536
tuple_count | 100000
tuple_len | 23200000
tuple_percent | 48.14
dead_tuple_count | 0
dead_tuple_len | 0
dead_tuple_percent | 0
free_space | 24075876
free_percent | 49.96$ psql -U postgres -x -c "SELECT * FROM pgstattuple('narrow')"table_len | 3629056
tuple_count | 100000
tuple_len | 3200000
tuple_percent | 88.18
dead_tuple_count | 0
dead_tuple_len | 0
free_percent | 0.51Zero dead tuples and 49.96% free space. A monitoring system that
alerts on n_dead_tup reports the first table as perfectly healthy
while it occupies twice the space it needs, reads twice as many pages
per scan, and consumes twice its share of shared buffers.
| Question | Metric |
|---|---|
| Is vacuum keeping up? | n_dead_tup, last_autovacuum |
| How much space is the table wasting? | pgstattuple.free_percent |
Watch both. They fail independently.
Measuring it
pgstattuple is exact, and reads the entire relation. On a 500 GB
table that is 500 GB of I/O, so it is a deliberate action, not
something to put on a five-minute dashboard.
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('orders'); -- exact, full scan
SELECT * FROM pgstattuple_approx('orders'); -- sampled, much cheaper
SELECT * FROM pgstatindex('orders_pkey'); -- index-specific
pgstattuple_approx samples and uses the visibility map to skip pages
it can trust, which makes it usable on large tables at the cost of
precision. Use it for routine checks and the exact version to confirm
before acting.
Estimation queries based on pg_class and pg_stats avoid reading
the table at all, computing an expected size from column widths and row
counts and comparing it against the actual size. They are what most
monitoring systems use, and they are genuinely useful for finding
candidates.
They are also estimates, and they are wrong in predictable
directions: they do not know about fillfactor, they handle TOAST and
alignment padding approximately, and they can report substantial bloat
on a table that has none. Treat an estimation query as a way to decide
which tables deserve a pgstattuple run, never as grounds for taking a
lock.
What VACUUM FULL actually costs
VACUUM FULL rewrites the table into a fresh file with no free space,
then swaps it in and drops the old one. It works, and the numbers are
worth knowing before you schedule one.
$ psql -U postgres -c "VACUUM FULL vac_scatter" -x -c "SELECT * FROM pgstattuple('vac_scatter')"Time: 64.011 ms
table_len | 24100864 (was 48193536)
tuple_percent | 96.26 (was 48.14)
free_percent | 1.79 (was 49.96)Now the cost. A 4,100 MB table; VACUUM FULL started, and one second
later a plain SELECT count(*) was issued from another session with a
four second lock_timeout.
$ psql -U postgres -c "SET lock_timeout='4s'; SELECT count(*) FROM big_bloat"ERROR: canceling statement due to lock timeout
Time: 4000.665 ms (00:04.001)
-- meanwhile, in the other session:
VACUUM FULL big_bloat;
Time: 5707.921 ms (00:05.708)
-- size: 4100 MB -> 2733 MB$ psql -U postgres -c "SELECT l.pid, l.mode, l.granted, c.relname FROM pg_locks l JOIN pg_class c ON c.oid=l.relation WHERE c.relname LIKE 'big_bloat%'" pid | mode | granted | relname
-----+---------------------+---------+----------------
245 | AccessExclusiveLock | t | big_bloat_pkey
245 | ShareLock | t | big_bloat
245 | AccessExclusiveLock | t | big_bloat
247 | ShareLock | t | big_bloat
247 | AccessExclusiveLock | t | big_bloat_pkey
(5 rows)Three things to take from that.
It blocks reads. ACCESS EXCLUSIVE conflicts with everything,
including SELECT. For the whole duration, the table does not exist as
far as the application is concerned.
It needs disk for a second complete copy. 4,100 MB and 2,733 MB had
to exist simultaneously. A VACUUM FULL on a table larger than the
remaining free space fails partway through — and it tends to be run
precisely when disk is short, which is what makes this trap effective.
The duration scales with the table. 5.7 seconds for 4 GB on this hardware. Measure your own rate on a copy before committing to a window; do not extrapolate from someone else’s number.
What to take from this
n_dead_tupmeasures vacuum’s backlog.pgstattuple.free_percentmeasures bloat. A table can be perfect on one and terrible on the other.- Estimation queries find candidates.
pgstattupledecides. - Free space in a churning table is the working set, not damage.
VACUUM FULLblocks reads, needs disk for a second copy, and takes time proportional to the table.REINDEX CONCURRENTLYfirst, an online rewrite tool second,VACUUM FULLwhen the window exists. Dropping a partition beats all of them when the problem is retention.
Cross-course references
- Linux for Production Sysadmins — Part XIV (Filesystems) covers why the space a rewrite needs is not the space it eventually returns, and Part XLI (Storage Performance) covers measuring the cost of the rewrite itself.
- Observability for Production Sysadmins — Part XXVII (Dashboard anti-patterns) covers why a single bloat estimate on a dashboard is worse than no number at all.
Quiz
Knowledge check · 6 questions
Q1. Monitoring reports a table as healthy because n_dead_tup is zero, yet queries against it read twice as many pages as the row count suggests they should. What is the most likely explanation?
Q2. A 900 GB table is reported as 40% bloated and the server has 500 GB of free disk. A VACUUM FULL is proposed for the maintenance window. What is the immediate problem?
Q3. A table under constant update traffic holds steady at 3 TB with pgstattuple reporting 30% free space, unchanged for six months. What is the appropriate action?
Q4. Which statements about REINDEX CONCURRENTLY are correct? Select all that apply.
Q5. VACUUM FULL blocks writes but allows concurrent reads, since readers can still use the original copy of the table while the new one is built.
Q6. A monitoring dashboard estimates bloat from pg_class and pg_stats and flags a table at 45%. What would you do before scheduling any rewrite?
Passing score: 75%. Answers are checked in this browser.