DB Internals AtlasPostgreSQL 18.6 Home About

Maintenance

VACUUM

UPDATE and DELETE leave dead row versions behind. VACUUM finds them, removes their index entries, frees their space for reuse and updates the visibility map.

In plain words

Because of MVCC, an UPDATE leaves the old version of a row behind and a DELETE only marks the row as gone. These leftovers are dead tuples. They still take space in the table and still have entries in every index.

VACUUM is the cleaner. It walks through the table, writes down the address of every dead tuple, removes the matching index entries, and then marks the space in the table as free so new rows can reuse it. The table file usually does not get smaller: the space is kept for future rows. Only completely empty pages at the very end can be handed back to the operating system.

Plain VACUUM runs alongside normal reads and writes. VACUUM FULL is different: it copies the live rows into a brand-new, compact file, which really shrinks the table but locks it completely while it runs.

You rarely run VACUUM by hand: autovacuum does it for you when enough rows have changed.

For DBAs and operators

The phases

  1. Scanning heap: reads every page not marked all-visible in the visibility map (all-visible pages are only skipped in runs of at least 32, to keep OS read-ahead effective). It first prunes each page (removes dead tuples' data, leaving short "dead item identifiers"), freezes old tuples, and records the TIDs (block, offset) of dead items.
  2. Vacuuming indexes: every index is scanned once and entries that point to a collected TID are deleted. Several indexes can be processed by parallel workers (VACUUM (PARALLEL n), manual VACUUM only).
  3. Vacuuming heap: the dead item identifiers are set to unused, space is recorded in the free space map, pages are marked all-visible (and all-frozen) where possible.
  4. Cleaning up indexes and truncating heap: empty pages at the end of the file are returned to the OS. Truncation needs a short ACCESS EXCLUSIVE lock and is skipped if it cannot get it; disable it with vacuum_truncate = off (a server setting since PostgreSQL 18) or the vacuum_truncate table option (replicas replay the lock too).

The TID store (PostgreSQL 17+)

Dead TIDs are kept in memory limited by maintenance_work_mem (or autovacuum_work_mem). Before 17 it was a flat array of 6-byte TIDs capped at 1GB, so big tables often needed several index passes ("index scans: 3"). Since 17 it is a compact radix tree (TidStore) that needs far less memory (the PostgreSQL 17 announcement cites up to 20× less) and is no longer capped at 1GB. If it fills up anyway, VACUUM pauses the heap scan, vacuums all indexes, empties the store and continues: every extra pass rereads every index.

VACUUM vs VACUUM FULL

VACUUMVACUUM FULL
LockSHARE UPDATE EXCLUSIVE (reads and writes continue)ACCESS EXCLUSIVE (everything waits)
File sizeReuses space; trims only empty tail pagesRewrites table and indexes compactly
Extra diskNoneA full copy of table plus indexes

For online shrinking use pg_repack or pg_squeeze; better still, keep autovacuum frequent enough that bloat never builds up.

Watching it

SELECT pid, relid::regclass, phase, heap_blks_total, heap_blks_scanned,
       heap_blks_vacuumed, index_vacuum_count,
       dead_tuple_bytes, max_dead_tuple_bytes, num_dead_item_ids,
       indexes_total, indexes_processed, delay_time
  FROM pg_stat_progress_vacuum;

delay_time (PostgreSQL 18) is only filled when track_cost_delay_timing is on. An autovacuum run logged because log_autovacuum_min_duration was exceeded looks like this (captured on PostgreSQL 18.6; the final system usage: CPU: … line is omitted):

LOG: automatic vacuum of table "atlas_t2b.public.t": index scans: 1 pages: 19 removed, 1103 remain, 243 scanned (21.66% of total), 0 eagerly scanned tuples: 4999 removed, 91376 remain, 0 are dead but not yet removable removable cutoff: 1505, which was 1 XIDs old when operation ended frozen: 234 pages from table (20.86% of total) had 20868 tuples frozen visibility map: 242 pages set all-visible, 242 pages set all-frozen (0 were all-visible) index scan needed: 57 pages from table (5.08% of total) had 4999 dead item identifiers removed index "t_pkey": pages: 551 in total, 0 newly deleted, 54 currently deleted, 54 reusable avg read rate: 57.198 MB/s, avg write rate: 24.489 MB/s buffer usage: 375 hits, 1002 reads, 429 dirtied WAL usage: 917 records, 372 full page images, 2816704 bytes, 0 buffers full

Read it as: only 21.66% of pages were scanned (the rest were all-visible), one index pass was enough, 19 empty tail pages were truncated. "eagerly scanned" is new in PostgreSQL 18 (see eager freezing). VACUUM (VERBOSE) prints the same block.

"dead but not yet removable" greater than zero means some snapshot (a long transaction, an idle-in-transaction session, a replication slot or hot_standby_feedback) still needs those versions. VACUUM cannot fix that; ending the old transaction can.

For developers: inside the source

Where it lives

  • vacuum.c: the command. ExecVacuum() → vacuum() → vacuum_rel() (takes ShareUpdateExclusiveLock, or AccessExclusive for FULL) → table_relation_vacuum(), the table AM callback.
  • vacuumlazy.c: the heap implementation, with a long header comment on the phases and the dead-items store. Pruning and freezing are in pruneheap.c, the visibility map in visibilitymap.c.
  • tidstore.c (TidStore, on the radix tree in src/include/lib/radixtree.h) and vacuumparallel.c (parallel index vacuuming).

Key data structures

LVRelState holds the whole run: cutoffs (struct VacuumCutoffs), aggressive, dead_items (a TidStore) with dead_items_info->max_bytes from maintenance_work_mem/autovacuum_work_mem, the counters printed in the log line, and the eager-scan state. Index AMs implement ambulkdelete and amvacuumcleanup (IndexAmRoutine).

Call flow

  1. heap_vacuum_rel(): cutoffs, dead_items_alloc(), then lazy_scan_heap().
  2. lazy_scan_heap() reads blocks through a read stream fed by heap_vac_scan_next_block(), whose find_next_unskippable_block() applies the VM and SKIP_PAGES_THRESHOLD (32). Per page it tries ConditionalLockBufferForCleanup(): with the cleanup lock it runs lazy_scan_prune() → heap_page_prune_and_freeze(); without it, lazy_scan_noprune() handles the page under a share lock, and only an aggressive vacuum that must freeze the page then waits in LockBufferForCleanup(). Dead item TIDs go to dead_items_add().
  3. When TidStoreMemoryUsage() exceeds max_bytes, lazy_vacuum() runs: lazy_vacuum_all_indexes() (each index via vac_bulkdel_one_index(), or parallel_vacuum_bulkdel_all_indexes()), then lazy_vacuum_heap_rel() → lazy_vacuum_heap_page() sets LP_DEAD items to unused and updates FSM and VM.
  4. Finally lazy_cleanup_all_indexes() and, if should_attempt_truncation(), lazy_truncate_heap(), which retries a conditional AccessExclusiveLock (every VACUUM_TRUNCATE_LOCK_WAIT_INTERVAL = 50 ms, gives up after VACUUM_TRUNCATE_LOCK_TIMEOUT = 5 s).

Phase changes are reported with pgstat_progress_update_param() and the PROGRESS_VACUUM_PHASE_* constants, which pg_stat_progress_vacuum displays. Cost-based sleeping happens in vacuum_delay_point().

Observe and test

  • Contrib: pg_visibility (VM bits per page), pageinspect (line pointer states), pgstattuple (dead tuple share), pg_freespacemap, amcheck's verify_heapam().
  • Tests: regression vacuum.sql and vacuum_parallel.sql, src/test/modules/test_tidstore, 048_vacuum_horizon_floor.pl.
  • Extension point: a table AM supplies its own relation_vacuum callback; heap's is heap_vacuum_rel().

Parameters that shape this

maintenance_work_mem autovacuum_work_mem vacuum_truncate vacuum_buffer_usage_limit vacuum_cost_delay vacuum_cost_limit vacuum_freeze_min_age vacuum_max_eager_freeze_failure_rate track_cost_delay_timing

Related topics

MVCC: row versions Autovacuum: when tables get vacuumed Autovacuum launcher and workers Free space map and visibility map HOT updates XID wraparound and freezing ANALYZE and statistics Page layout