Skip to main content
RunBook Academy

PostgreSQLVIII · VACUUM, Autovacuum and WraparoundVacuum

Measuring bloat honestly

Advanced⏱ ~30 min🧪 Lab requiredpsql

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

Not yet marked complete on this device.

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.

Read-only / Safea table with zero dead tuples that is half empty
$ 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
Read-only / Safea healthy table, for comparison
$ 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.51

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

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

Read-only / Safethe result on the half-empty table
$ 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.

Destructivea plain SELECT during VACUUM FULL
$ 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
Read-only / Safethe locks it holds while running
$ 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_tup measures vacuum’s backlog. pgstattuple.free_percent measures bloat. A table can be perfect on one and terrible on the other.
  • Estimation queries find candidates. pgstattuple decides.
  • Free space in a churning table is the working set, not damage.
  • VACUUM FULL blocks reads, needs disk for a second copy, and takes time proportional to the table.
  • REINDEX CONCURRENTLY first, an online rewrite tool second, VACUUM FULL when 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

  1. 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?

  2. 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?

  3. 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?

  4. Q4. Which statements about REINDEX CONCURRENTLY are correct? Select all that apply.

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

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