Topic 36

Measuring and Fixing Bloat

Bloat Control

Bloat is the gap between the space a relation occupies and the space its live rows need. A little of it is the system working: the free room a HOT update lands in, the space the next insert will take. A lot of it is a tax on every read, because the same rows now span five or fifty times as many pages and every sequential scan pays for all of them.

There are two skills here, and they are usually confused with each other. One is measuring bloat honestly, which is harder than it sounds because the well-known estimate is only an estimate. The other is choosing between the three ways to remove it, which differ mainly in what they lock and how much disk they need. Both come before the more important question, which is whether removing it fixes anything at all.

Estimate, measure, rewrite, and then the step people skip
Estimate from the catalogueright for a dashboard and an alert
Confirm with pgstattuplereads the relation and counts what is actually there
Choose the rewriteor choose to live with it
Fix what generates itsettings, schema, the vacuum horizon, partitioning

Estimating Versus Measuring

The bloat queries that circulate on blogs and in monitoring tools derive a number from the catalogue: the page count and row estimate in pg_class, and the average column widths ANALYZE recorded in pg_statistic. Multiply the widths by the row estimate, add per-tuple overhead, compare against the actual page count, and the difference is called bloat. It costs nothing to run, it can be sampled every minute across every table, and it is the right tool for a dashboard and an alert.

It is also wrong more often than its two decimal places suggest. It leans on estimates that are themselves stale between analyzes, and it misjudges tables with many NULLs, with widely varying value widths, or with TOASTed columns whose stored size has nothing to do with the declared type. A 30% reading from an estimate query is a reason to look closer. It is not a reason to take an outage.

Measuring inventory for real, before touching anything
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT table_len, tuple_count, dead_tuple_count,
       round(tuple_percent::numeric,      2) AS live_pct,
       round(dead_tuple_percent::numeric, 2) AS dead_pct,
       round(free_percent::numeric,       2) AS free_pct
  FROM pgstattuple('inventory');

 table_len | tuple_count | dead_tuple_count | live_pct | dead_pct | free_pct
-----------+-------------+------------------+----------+----------+----------
 943718400 |       12000 |             5104 |     0.05 |     0.02 |    99.79

pgstattuple is a contrib extension that reads the relation and counts what is actually there — exact live and dead tuple counts, exact free space, no inference. The price is the scan, which on a large table is real I/O, and the fact that the answer is a moving average across a running system rather than an instant. Execution is restricted to superusers and members of pg_stat_scan_tables. Where the scan is too expensive, pgstattuple_approx skips the pages the visibility map marks all-visible: its live figures are estimates, its dead figures are still exact, and it reports what fraction of the table it managed to read.

The reading on inventory is the whole chapter in one row. Twelve thousand live rows accounting for 0.05% of a 900 MB file. Five thousand dead versions, under one percent, because autovacuum has been keeping up perfectly since the previous two topics. Nothing is wrong with this table any more; it is simply nine hundred megabytes wide and never going to shrink on its own. The last column reads 99.79% free.

Index Bloat Is a Separate Number

Indexes bloat on their own schedule and vacuum never shrinks them. A B-tree leaf page that fills up splits into two roughly half-full pages, and Postgres does not merge them back when the keys are later deleted — only a wholly empty page is recycled. Insert keys in random order, which is what a version-4 UUID or a high update rate does, and the index settles at a leaf density well below what a fresh build would produce. Every plan that uses it then reads more pages for the same rows.

Leaf density on inventory's primary key, after four years of updates
SELECT * FROM pgstatindex('inventory_pkey');

 version | tree_level | index_size | leaf_pages | avg_leaf_density | leaf_fragmentation
---------+------------+------------+------------+------------------+--------------------
       4 |          1 |     753664 |         86 |            41.63 |              28.94

pgstatindex, from the same extension, reports the structure of a B-tree: how deep it is, how many leaf pages it has, and the two numbers that matter — avg_leaf_density, the average fill of those leaf pages, and leaf_fragmentation, how far their physical order has drifted from their key order. A freshly built index sits near its fillfactor of 90. This one is at 41.6%, which means it is carrying roughly twice the pages it needs, and no amount of vacuuming will change that number. Rebuilding is the only cure, and Chapter 8 covers how to do it on a live table without blocking writes.

VACUUM FULL

VACUUM FULL is the honest sledgehammer. It writes the entire contents of the table into a new file with no wasted space, rebuilds every index as part of the same operation, and then swaps them in and drops the originals. Because the old copy is not released until the new one is complete, the operation needs free disk for a second copy of the largest relation involved. And it holds an ACCESS EXCLUSIVE lock for the whole rewrite, which means no reads, no writes, and, because of how the lock queue works, everything arriving behind it waiting too.

The Sunday-morning window that ended the 900 MB
SET lock_timeout = '5s';      -- fail fast rather than stall checkout
VACUUM (FULL, ANALYZE, VERBOSE) inventory;

-- before: 900 MB     after: 616 kB    lock held: 71 seconds

Under half a megabyte of live rows came out of a 900 MB file and was written back at fillfactor 85 across 77 pages, which is where the 616 kB comes from — the 15% held back on every page is deliberate, and it is what keeps the HOT updates from the previous topic working. Setting lock_timeout first is not optional on a live system: this rewrite forms the same queue Chapter 6 described behind any ACCESS EXCLUSIVE request, so one long-running reader can stall the whole table with a rewrite that never even started. Failing after five seconds and retrying is cheaper than explaining the alternative.

pg_repack

pg_repack does the same job while the table stays online. It builds a copy in the background, uses triggers to capture the changes that arrive while it works, applies them, and then takes a brief exclusive lock only for the final swap. The disk requirement is the same second copy, and it adds two things VACUUM FULL does not have: moving parts that can fail partway and leave temporary objects behind, and a dependency on an extension being installed, which is a question you have to ask your managed provider before you plan around it. For a system that has no window in which a table can be unavailable for a minute, it is the right answer, and Chapter 14 covers what a provider does and does not let you install.

Rebuilding Indexes

When the table is fine and the indexes are not, the whole rewrite is the wrong tool. REINDEX CONCURRENTLY builds a fresh copy of one index while reads and writes carry on, then swaps it in — the routine answer for the density number pgstatindex reports. It needs room for both copies at once, and a failed run can leave an invalid index behind that has to be cleaned up by hand. Chapter 8 owns the procedure and its failure modes; the point here is that it is a separate operation with a separate cost, and doing the table without the indexes leaves the plans exactly as slow as they were.

Which remedy, and what each one asks for
A window in which the table can be offline for the durationVACUUM FULL
No window at all, and the extension is available on this serverpg_repack
The table is fine and only the leaf pages are half emptyREINDEX CONCURRENTLY
Bloat that is stable, and ongoing writes keep reusing the free spaceLeave it alone

Fixing the Generator

Every rewrite on this page removes a symptom. Four upstream causes produce it, and if none of them is addressed the same rewrite goes back on the calendar for next quarter. Per-table autovacuum settings that actually match the table's size and rate. A schema that lets updates be HOT — the right columns indexed and room reserved on the page. No transaction, replication slot or prepared transaction sitting on the vacuum horizon for hours, which Chapter 6 covers in detail. And detaching a partition instead of deleting ten million rows from a live table, which Chapter 11 covers when delivery_events gets partitioned.

A VACUUM FULL that has to be repeated on a schedule is a note in the calendar saying one of those four was never fixed. Cartwheel's inventory needed exactly one rewrite, and it needed it after the settings and the schema were corrected, because a rewrite performed first would have been 900 MB again inside a month.

The last thing to say about bloat is that a great deal of it should be left alone. A table sitting at a stable 20% with a write pattern that keeps reusing the same free space is not a problem; the free space is doing its job. Everything else is an outage bought with a dashboard. The number worth acting on is one that is both large and still growing, on a table whose reads you can show are paying for it.

VACUUM FULL vs pg_repack vs living with it

VACUUM FULL — built in, no extension, rebuilds the indexes with the table, needs double the disk, and locks the relation for the entire rewrite. Right when you have a window in which the table can be offline for the duration, and on a small table that duration is under two minutes.

pg_repack — keeps the table readable and writable throughout and takes an exclusive lock only at the swap. Needs the same double disk, an installed extension, and tolerance for a longer operation with more that can go wrong. Right for a system with no window at all.

Living with it — right far more often than a dashboard suggests. Bloat that is stable and reused by ongoing writes costs nothing, and the rewrite only pays for itself if the write pattern that created the free space has actually changed.

Common Mistakes
  • Running VACUUM FULL on a live 200 GB table because a dashboard showed a bloat estimate — an hour of downtime to reclaim space the table would have reused by itself.
  • Trusting an estimate query to two decimal places — the estimates are known to drift on tables with NULLs, variable widths and TOASTed columns, so confirm with pgstattuple before anything irreversible.
  • Rewriting the table and leaving the indexes alone — the file shrinks, the plans stay slow, because the leaf pages are still half empty.
  • Repacking a table whose bloat comes from a forgotten replication slot — it is back within days, and the actual fix was one DROP statement.
  • Starting a rewrite without checking free disk against the relation's total size — both VACUUM FULL and pg_repack need a full second copy, and the failure mode is a disk that fills halfway through.
  • Treating pg_repack as though it never locks at all — it needs a brief ACCESS EXCLUSIVE moment to swap the finished copy in, and with no lock_timeout that moment queues behind whatever is already reading.
Best Practices
  • Alert on the cheap estimate, confirm with pgstattuple, and act only when the number is both large and still climbing.
  • Measure indexes separately with pgstatindex and treat avg_leaf_density as its own metric, since vacuum never improves it.
  • Fix the generator before the symptom: autovacuum settings, the HOT ratio, the oldest open transaction, and mass deletes that should have been partition detaches.
  • Set lock_timeout before any statement that takes an ACCESS EXCLUSIVE lock, and retry rather than let the queue build.
  • Check pg_total_relation_size against actual free disk before a rewrite, and include the indexes in the arithmetic.
  • Record the before and after sizes of every rewrite in the runbook, so a second one on the same table is visibly a repeat rather than routine work.
Comparable toolspgstattuple exact bloat measurement, in contribpg_repack and pg_squeeze online table rewritesOracle segment shrink and online table redefinitionInnoDB OPTIMIZE TABLE, itself a full rewriteSQL Server index rebuild and reorganize

Knowledge Check

What is the practical difference between a bloat estimate query and pgstattuple?

  • The estimate infers from statistics; pgstattuple reads and counts
  • The estimate excludes uncommitted rows while pgstattuple includes them
  • The estimate needs an extension while pgstattuple is built into the server
  • The estimate is read-only while pgstattuple also prunes what it finds

A table is rewritten and its size drops by 90%, but the queries against it are no faster. What was missed?

  • The planner statistics were never refreshed after the rewrite
  • The indexes were bloated too and a rewrite does not fix their density
  • The rewritten table is excluded from shared buffers until restart
  • The visibility map was discarded, so index-only scans stopped working

What does VACUUM FULL require that plain VACUUM does not?

  • The MAINTAIN privilege, which plain vacuum does not check
  • An exclusive lock for the whole rewrite, and disk for a second copy
  • A surrounding transaction block, so the rewrite can be rolled back
  • A follow-up REINDEX, since it leaves the old indexes in place

Why do B-tree indexes bloat independently of the table they index?

  • Vacuum skips indexes entirely and never removes their dead entries
  • Each index keeps its own row versions, which age on their own schedule
  • A full leaf page splits in two and the halves are never merged back
  • Index fillfactor defaults to 100, so any insert forces a new page

Bloat on a table has been stable at 20% for six months and the write rate is unchanged. What is the right action?

  • Schedule a quarterly VACUUM FULL to keep the figure near zero
  • Leave it alone, because stable free space is being reused by writes
  • Lower the table's autovacuum scale factor until the figure drops
  • Repack it online so the space returns to the filesystem safely

You got correct