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
- 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.
- 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). - 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.
- 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 thevacuum_truncatetable 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
| VACUUM | VACUUM FULL | |
|---|---|---|
| Lock | SHARE UPDATE EXCLUSIVE (reads and writes continue) | ACCESS EXCLUSIVE (everything waits) |
| File size | Reuses space; trims only empty tail pages | Rewrites table and indexes compactly |
| Extra disk | None | A 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):
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()(takesShareUpdateExclusiveLock, 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 inpruneheap.c, the visibility map invisibilitymap.c.tidstore.c(TidStore, on the radix tree insrc/include/lib/radixtree.h) andvacuumparallel.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
heap_vacuum_rel(): cutoffs,dead_items_alloc(), thenlazy_scan_heap().lazy_scan_heap()reads blocks through a read stream fed byheap_vac_scan_next_block(), whosefind_next_unskippable_block()applies the VM andSKIP_PAGES_THRESHOLD(32). Per page it triesConditionalLockBufferForCleanup(): with the cleanup lock it runslazy_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 inLockBufferForCleanup(). Dead item TIDs go todead_items_add().- When
TidStoreMemoryUsage()exceedsmax_bytes,lazy_vacuum()runs:lazy_vacuum_all_indexes()(each index viavac_bulkdel_one_index(), orparallel_vacuum_bulkdel_all_indexes()), thenlazy_vacuum_heap_rel()→lazy_vacuum_heap_page()sets LP_DEAD items to unused and updates FSM and VM. - Finally
lazy_cleanup_all_indexes()and, ifshould_attempt_truncation(),lazy_truncate_heap(), which retries a conditional AccessExclusiveLock (everyVACUUM_TRUNCATE_LOCK_WAIT_INTERVAL= 50 ms, gives up afterVACUUM_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'sverify_heapam(). - Tests: regression
vacuum.sqlandvacuum_parallel.sql,src/test/modules/test_tidstore,048_vacuum_horizon_floor.pl. - Extension point: a table AM supplies its own
relation_vacuumcallback; heap's isheap_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