OnCallReady

Lesson 25.24 · PostgreSQL Operations · 28 min read

VACUUM, autovacuum, bloat and XID wraparound

In plain words

Every update in PostgreSQL leaves the old copy of the row behind, like crossed-out lines in a notebook. VACUUM is the cleaner who comes by and turns crossed-out lines into free space for new writing. But the cleaner is not allowed to erase anything a reader might still be looking at - and one reader who sits with the notebook open for days means nothing can be cleaned.

There is also a counter on every page that, if never reset, would eventually roll over like an old car's odometer; VACUUM resets it in time.

Why this lesson exists

"Everything got slower over the last weeks and the database keeps growing" - and nobody changed anything. The usual reason is that VACUUM is not keeping up: dead row versions pile up, tables and indexes bloat, queries read ever more pages, and in the worst case PostgreSQL runs out of transaction IDs and stops accepting writes to protect your data. VACUUM is the one maintenance job every PostgreSQL needs and the one an SRE is expected to understand: what it does, when autovacuum runs it, and what silently stops it from working.

What you need to know already: MVCC, dead tuples, xmin and idle-in-transaction sessions (the last lesson), pg_stat_activity, setting contexts and reload (lesson 2).

The words you need first

Dead tuples, live

The lab has a busy table, events. Look at its statistics:

$ sudo -u postgres psql -d orders -c "select relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum, pg_size_pretty(pg_total_relation_size(relid)) as size from pg_stat_user_tables where relname = 'events'"
 relname | n_live_tup | n_dead_tup | last_vacuum | last_autovacuum |  size
---------+------------+------------+-------------+-----------------+---------
 events  |      30000 |      25000 |             |                 | 5112 kB
(1 row)

Tens of thousands of dead tuples from two UPDATEs, and no vacuum yet. Run one by hand and read what it says:

$ sudo -u postgres psql -d orders -c "vacuum (verbose) events"
INFO:  vacuuming "orders.public.events"
INFO:  finished vacuuming "orders.public.events": index scans: 1
pages: 0 removed, 468 remain, 468 scanned (100.00% of total), 0 eagerly scanned
tuples: 10000 removed, 30000 remain, 0 are dead but not yet removable
removable cutoff: 826, which was 0 XIDs old when operation ended
new relfrozenxid: 768, which is 5 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 468 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan needed: 421 pages from table (89.96% of total) had 19988 dead item identifiers removed
index "events_pkey": pages: 167 in total, 0 newly deleted, 0 currently deleted, 0 reusable
I/O timings: read: 0.000 ms, write: 0.005 ms
avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s
buffer usage: 1552 hits, 0 reads, 3 dirtied
WAL usage: 1479 records, 3 full page images, 223622 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
INFO:  vacuuming "orders.pg_toast.pg_toast_24702"
INFO:  finished vacuuming "orders.pg_toast.pg_toast_24702": index scans: 0
pages: 0 removed, 0 remain, 0 scanned (100.00% of total), 0 eagerly scanned
tuples: 0 removed, 0 remain, 0 are dead but not yet removable
removable cutoff: 826, which was 0 XIDs old when operation ended
new relfrozenxid: 826, which is 63 XIDs ahead of previous value
frozen: 0 pages from table (100.00% of total) had 0 tuples frozen
visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan not needed: 0 pages from table (100.00% of total) had 0 dead item identifiers removed
I/O timings: read: 0.021 ms, write: 0.000 ms
avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s
buffer usage: 9 hits, 1 reads, 0 dirtied
WAL usage: 2 records, 0 full page images, 394 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM

The line to read is tuples: N removed, M remain, K are dead but not yet removable, then removable cutoff - the xmin horizon this VACUUM used. Here everything dead was removed, because no other transaction was open. The table did not shrink (pg_size_pretty would show the same size): the space is now free inside the table, reused by the next inserts and updates.

What stops VACUUM: the horizon

Now a session opens a transaction and sits on it - say a report that started, read a bit, and went to make coffee:

$ sudo -u postgres psql -d orders -qc "update events set processed = true where id % 5 = 0"
$ sudo -u postgres psql -d orders -c "vacuum (verbose) events" 2>&1 | grep -E 'tuples:|removable cutoff'
tuples: 0 removed, 36000 remain, 6000 are dead but not yet removable
removable cutoff: 826, which was 2 XIDs old when operation ended
tuples: 0 removed, 0 remain, 0 are dead but not yet removable
removable cutoff: 826, which was 2 XIDs old when operation ended

... are dead but not yet removable: the dead tuples are newer than the horizon, and the open transaction might still need to see them. VACUUM ran, did its work, and achieved nothing. Who holds the horizon:

$ sudo -u postgres psql -c "select pid, application_name, state, backend_xmin, age(backend_xmin) as xmin_age, now() - xact_start as xact_age from pg_stat_activity where backend_xmin is not null order by age(backend_xmin) desc"
  pid  | application_name |        state        | backend_xmin | xmin_age |  xact_age
-------+------------------+---------------------+--------------+----------+------------
 17419 | nightly-report   | idle in transaction |          826 |        2 | 00:00:30.5
 17428 | psql             | active              |          826 |        2 |
(2 rows)

The four things that hold the horizon back - check all four, in this order:

holderwhere you see itfix
a long or idle-in-transaction sessionpg_stat_activity.backend_xmin, xact_startend it; idle_in_transaction_session_timeout
a forgotten prepared transaction (PREPARE TRANSACTION)pg_prepared_xactsCOMMIT PREPARED / ROLLBACK PREPARED
a replica with hot_standby_feedback = on running a long querypg_stat_replication.backend_xminend the query on the replica
a replication slot (logical, or physical with feedback)pg_replication_slots.xmin / catalog_xmindrop the stale slot (lesson 13)

End the report and vacuum again:

$ sudo -u postgres psql -Atc "select pg_terminate_backend(pid) from pg_stat_activity where application_name = 'nightly-report'"
t
$ sudo -u postgres psql -d orders -c "vacuum (verbose) events" 2>&1 | grep -E 'tuples:|removable cutoff'
tuples: 6000 removed, 30000 remain, 0 are dead but not yet removable
removable cutoff: 830, which was 0 XIDs old when operation ended
tuples: 0 removed, 0 remain, 0 are dead but not yet removable
removable cutoff: 830, which was 0 XIDs old when operation ended

Removed. One open transaction on a busy system for a weekend is how "everything got slow since Friday" happens: autovacuum runs and runs, removes nothing, and every table bloats.

autovacuum

You almost never run VACUUM by hand; autovacuum does, per table, when enough has changed:

vacuum   when  dead tuples       >  autovacuum_vacuum_threshold (50)  + autovacuum_vacuum_scale_factor (0.2) x rows
                                    (capped at autovacuum_vacuum_max_threshold, new in 18: 100 million)
         or    inserted tuples   >  autovacuum_vacuum_insert_threshold (1000) + insert_scale_factor (0.2) x rows
analyze  when  changed tuples    >  autovacuum_analyze_threshold (50) + autovacuum_analyze_scale_factor (0.1) x rows
$ sudo -u postgres psql -c "select name, setting, unit from pg_settings where name like 'autovacuum%' order by name"
                 name                  |  setting  | unit
---------------------------------------+-----------+------
 autovacuum                            | on        |
 autovacuum_analyze_scale_factor       | 0.1       |
 autovacuum_analyze_threshold          | 50        |
 autovacuum_freeze_max_age             | 200000000 |
 autovacuum_max_workers                | 3         |
 autovacuum_multixact_freeze_max_age   | 400000000 |
 autovacuum_naptime                    | 60        | s
 autovacuum_vacuum_cost_delay          | 2         | ms
 autovacuum_vacuum_cost_limit          | -1        |
 autovacuum_vacuum_insert_scale_factor | 0.2       |
 autovacuum_vacuum_insert_threshold    | 1000      |
 autovacuum_vacuum_max_threshold       | 100000000 |
 autovacuum_vacuum_scale_factor        | 0.2       |
 autovacuum_vacuum_threshold           | 50        |
 autovacuum_work_mem                   | -1        | kB
 autovacuum_worker_slots               | 16        |
(16 rows)

The launcher wakes every autovacuum_naptime (1 min) per database and starts up to autovacuum_max_workers (3) workers. Each worker throttles itself (autovacuum_vacuum_cost_limit / cost_delay) so it does not hurt the application - the defaults are gentle on purpose and often too gentle for a busy, large database.

The 20% default is the classic problem on big tables: a table with 500 million rows waits for 100 million dead tuples before autovacuum touches it (the 18 cap helps). Tune the hot tables, not the whole server:

$ sudo -u postgres psql -d orders -c "alter table events set (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 1000)"
ALTER TABLE
$ sudo -u postgres psql -d orders -c "select relname, reloptions from pg_class where relname = 'events'"
 relname |                               reloptions
---------+------------------------------------------------------------------------
 events  | {autovacuum_vacuum_scale_factor=0.01,autovacuum_vacuum_threshold=1000}
(1 row)

The other dangerous reloption is autovacuum_enabled = false - someone turns it off "during a bulk load" and forgets. \d+ events shows it under Options:. And make autovacuum visible: log_autovacuum_min_duration (default 10min since 15) logs every autovacuum that takes longer; set 0 while you investigate and you will see each run with the same tuples: removed ... dead but not yet removable line.

What is running right now:

$ sudo -u postgres psql -c "select pid, datname, relid::regclass, phase, heap_blks_scanned, heap_blks_total from pg_stat_progress_vacuum"
 pid | datname | relid | phase | heap_blks_scanned | heap_blks_total
-----+---------+-------+-------+-------------------+-----------------
(0 rows)

(Empty when nothing is being vacuumed. Autovacuum workers also show in pg_stat_activity as backend_type = 'autovacuum worker' with a query like autovacuum: VACUUM public.events.)

Bloat and getting space back

VACUUM makes space reusable, it does not return it. A table that grew to 10 GB during a bloat incident stays 10 GB on disk after the cleanup - mostly empty pages that future rows will fill. If you need the disk back:

toollocknotes
VACUUM FULL tableACCESS EXCLUSIVE for the whole rewriteneeds free disk for a full copy; an outage for that table
CLUSTER table USING indexACCESS EXCLUSIVEsame, and orders the rows
pg_repack (extension)brief exclusive locks only at start and endthe online way; installed on most managed services
REINDEX INDEX CONCURRENTLYnone that blocks writesindexes bloat too, and are often the bigger part

Measure before you act: pg_total_relation_size vs what the rows need, or the pgstattuple extension (select * from pgstattuple('events') gives dead_tuple_percent and free_percent).

Transaction ID wraparound

Every write transaction takes a 32-bit XID. Comparing XIDs works modulo 2^32, so a row is in "the past" only for about 2.1 billion transactions; after that it would suddenly look like it is in the future and disappear. VACUUM prevents that by freezing old rows (marking them as visible to everyone). The age of the oldest unfrozen XID per database:

$ sudo -u postgres psql -c "select datname, age(datfrozenxid) as xid_age, round(100.0 * age(datfrozenxid) / 2147483647, 2) as pct_to_wraparound from pg_database order by 2 desc"
  datname  | xid_age | pct_to_wraparound
-----------+---------+-------------------
 postgres  |      89 |              0.00
 template1 |      89 |              0.00
 template0 |      89 |              0.00
 orders    |      89 |              0.00
(4 rows)

What happens as the age grows:

agewhat PostgreSQL does
autovacuum_freeze_max_age (200 million)forces an anti-wraparound autovacuum on the table - even if autovacuum is off. It does not give up and is not cancelled by lock conflicts
40 million XIDs leftevery transaction logs WARNING: database "orders" must be vacuumed within 39985967 transactions
3 million leftrefuses anything that needs a new XID: ERROR: database is not accepting commands that assign new transaction IDs to avoid wraparound data loss in database "orders", with a HINT to vacuum that database (and to end old prepared transactions or drop stale slots)

The fix at that point is a database-wide VACUUM (or vacuumdb --all --freeze) - and first removing whatever held the horizon, otherwise it cannot freeze. Monitor age(datfrozenxid) and alert long before 200 million is in sight; on a healthy system it saw-tooths between 50 and 200 million as anti-wraparound vacuums run.

In an interview: "Autovacuum runs all the time but the tables keep bloating - why?" - VACUUM can only remove dead tuples older than the xmin horizon; something holds it back: a long or idle-in-transaction session (backend_xmin in pg_stat_activity), a forgotten prepared transaction, a replica's hot_standby_feedback, or a stale replication slot. VACUUM VERBOSE shows it as "dead but not yet removable". Remove the holder, then let autovacuum (or a manual VACUUM) catch up; tune per-table scale factors for big hot tables.

What you can do now

Why it helps

"Slow since last week and the database keeps growing" usually means VACUUM is not keeping up. You need to read n_dead_tup and VACUUM VERBOSE, recognise "dead but not yet removable", and find what holds the xmin horizon - a long or idle transaction, a prepared transaction, a replica, a slot.

Transaction ID wraparound is the rare but catastrophic version: ignored, it makes PostgreSQL refuse writes. Knowing the warning signs and the metric (age of datfrozenxid) keeps it in the "rare" column.

Commands in this lesson

psql

FAQ

Why does the table not shrink after VACUUM?

VACUUM marks the space of dead tuples reusable inside the table; future inserts and updates fill it. It only returns empty pages at the very end of the file to the operating system. To give space back you need VACUUM FULL or CLUSTER (both lock the table completely) or pg_repack, which rebuilds the table online with only short locks.

What does "dead but not yet removable" mean?

VACUUM found dead tuples that some running transaction, replica or slot might still need to see, because they died after the oldest snapshot in use - the xmin horizon. They stay until that holder goes. Look for old backend_xmin in pg_stat_activity, pg_prepared_xacts, backend_xmin in pg_stat_replication and xmin in pg_replication_slots.

When does autovacuum vacuum a table?

When its dead tuples exceed autovacuum_vacuum_threshold (50) plus autovacuum_vacuum_scale_factor (0.2) times its rows, or enough rows were inserted; since 18 the threshold is capped by autovacuum_vacuum_max_threshold. For big tables 20% is far too late, so set a smaller scale factor per table with ALTER TABLE ... SET.

Can I turn autovacuum off to make the database faster?

No. It looks cheaper for a few days, then tables and indexes bloat, plans read more pages, and freezing stops - heading toward wraparound. Even with autovacuum off, PostgreSQL still forces anti-wraparound vacuums. If autovacuum hurts, tune its cost limits or schedule heavy manual vacuums, never disable it.

What happens when XID wraparound gets close?

At autovacuum_freeze_max_age (200 million) an anti-wraparound autovacuum starts. With 40 million XIDs left every transaction warns that the database must be vacuumed within N transactions. With 3 million left the server refuses commands that need a new transaction ID. Remove whatever holds the horizon, then run VACUUM (FREEZE) on that database.

In an interview Mid

Autovacuum runs all the time but tables keep bloating. Why?

Because VACUUM can only remove dead tuples older than the xmin horizon, and something holds it back - VACUUM VERBOSE reports them as dead but not yet removable. The usual holders, in order: a long or idle in transaction session (backend_xmin and xact_start in pg_stat_activity), a forgotten prepared transaction, a replica with hot_standby_feedback running a long query, or a replication slot. Remove the holder, then let autovacuum or a manual VACUUM catch up. Also check that autovacuum is not disabled per table (autovacuum_enabled = false) and that big tables have a smaller scale factor.

Also asked: What does VACUUM do, and how is it different from VACUUM FULL? · What is transaction ID wraparound and how do you monitor it? · How would you reclaim disk space from a bloated table without downtime?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.