Skip to content

Bloat & vacuum health

Chapter 4 showed the mechanism with a microscope — dead tuples on the page, VACUUM reclaiming them, one old snapshot starving it all. This lesson is the dashboard version: the same facts from pg_stat_user_tables, the view your monitoring should already be scraping.

A> INSERT INTO inventory SELECT g, 10 FROM generate_series(1, 5) g;
INSERT 0 5

A> UPDATE inventory SET qty = qty + 1 WHERE id <= 3;
UPDATE 3

A> SELECT pg_stat_force_next_flush(); -- stats reach the views lazily; force it for the demo
 pg_stat_force_next_flush 
--------------------------
                          
(1 row)

Chapter 4 proved updates leave dead tuples behind; pg_stat_user_tables is where you SEE them:

M> SELECT relname, n_live_tup::int, n_dead_tup::int, last_vacuum IS NULL AS never_vacuumed
   FROM pg_stat_user_tables WHERE relname = 'inventory';
  relname  | n_live_tup | n_dead_tup | never_vacuumed 
-----------+------------+------------+----------------
 inventory |          5 |          3 | t              
(1 row)

VACUUM cleans up — and the same view proves it happened:

A> VACUUM inventory;
VACUUM

M> SELECT relname, n_live_tup::int, n_dead_tup::int, last_vacuum IS NOT NULL AS vacuumed
   FROM pg_stat_user_tables WHERE relname = 'inventory';
  relname  | n_live_tup | n_dead_tup | vacuumed 
-----------+------------+------------+----------
 inventory |          5 |          0 | t        
(1 row)

The one number that must never reach its limit: the database's xid age vs the emergency threshold.

M> SELECT age(datfrozenxid) < current_setting('autovacuum_freeze_max_age')::int AS wraparound_ok
   FROM pg_database WHERE datname = current_database();
 wraparound_ok 
---------------
 t             
(1 row)

Verified against PostgreSQL 18.4 · Run it yourself · Scenario source

Reading the dashboard

Start with n_dead_tup, the "Estimated number of dead rows". It's estimated because it comes from the statistics system rather than a table scan, which is what keeps it cheap enough to poll every minute. Watch its ratio to n_live_tup rather than the raw count: a queue table that's 99% dead tuples explains its own slow scans. When you need exact numbers, the pgstattuple extension scans for real.

last_vacuum and last_autovacuum tell you when cleanup last ran, manually or via the daemon. A hot table whose last_autovacuum is days old is either configured wrong or, more often, blocked by something holding the horizon — cross-check with detector 3.

age(datfrozenxid) against autovacuum_freeze_max_age is the wraparound margin, and the scenario renders it as a boolean on purpose because that's what your alert should be. Once the age reaches the threshold (200 million transactions by default), PostgreSQL forces an anti-wraparound autovacuum on the table, and it does so even if you've turned autovacuum off. Alert at half that margin and you'll never meet the forced pass.

Poll pg_stat_user_tables for the dead-to-live ratio and the age of last_autovacuum on your busiest tables, and bloat announces itself long before disk-full does. When vacuum looks like it "stopped working," the fault is almost never vacuum's — it's the oldest open transaction or an orphaned prepared transaction pinning the horizon. Wraparound stays a boolean rather than a graph to admire: age(datfrozenxid) < autovacuum_freeze_max_age / 2, or a human gets paged.

Further reading

MIT Licensed · Every transcript on this site was generated by a real database run against MySQL 8.4.10 and PostgreSQL 18.4 at bd6f201, and re-proven through psycopg and PyMySQL.