6.1 The PostgreSQL Vacuuming Engine & MVCC Space Reclamation

Key Takeaways

  • Multi-Version Concurrency Control (MVCC) ensures statement-level read consistency without read locks by creating new row versions on UPDATE and marking old versions dead via xmax, which leads to physical dead tuple accumulation and table/index bloat.
  • Standard VACUUM scans table pages to identify dead tuples, records unallocated page space in the Free Space Map (_fsm), updates all-visible bits in the Visibility Map (_vm), and reclaims space for future inserts without shrinking physical disk file size or blocking concurrent DML under a SHARE UPDATE EXCLUSIVE lock.
  • VACUUM FULL performs an offline table rewrite by copying only live tuples into a completely new physical data file, rebuilding all indexes and returning unused disk space to the operating system, but requires a disruptive ACCESS EXCLUSIVE lock that blocks all concurrent reads and writes.
  • The 32-bit transaction counter limit (2^32 = ~4.29 billion transactions, with a 2^31 circular horizon) introduces the threat of transaction ID (XID) wraparound; VACUUM FREEZE converts transaction IDs older than vacuum_freeze_min_age to FrozenTransactionId (XID 2), ensuring historical data remains visible indefinitely.
  • The autovacuum daemon automates vacuuming and statistics collection, triggering worker tasks based on configurable activity formulas (autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * n_live_tuples) and executing mandatory anti-wraparound vacuums once tables reach autovacuum_freeze_max_age.
Last updated: September 2026

6.1 The PostgreSQL Vacuuming Engine & MVCC Space Reclamation

[!NOTE] Core Design Philosophy: PostgreSQL uses Multi-Version Concurrency Control (MVCC) to achieve high-performance concurrency. In PostgreSQL, reads never block writes, and writes never block reads. However, this concurrency model comes with a physical storage consequence: modifications do not overwrite existing data in place. Instead, they write new row versions and leave expired ones behind. The vacuuming engine is the fundamental background subsystem responsible for cleaning up these expired row versions and preserving database health.

Understanding the mechanics of space reclamation, transaction ID freezing, and the autovacuum daemon is essential for any database administrator. Improper maintenance can lead to rampant storage bloat, degraded query throughput, and catastrophic cluster outages caused by transaction ID wraparound.


Multi-Version Concurrency Control (MVCC) and Tuple Lifecycle

In PostgreSQL, every table is stored as an array of fixed-size disk pages (by default, 8KB blocks). Rows stored within these data pages are termed heap tuples. To provide transaction isolation without acquiring shared read locks on table data, PostgreSQL embeds transaction visibility metadata directly into each tuple's header:

  • t_xmin: The Transaction ID (XID) of the inserting transaction that created this tuple version.
  • t_xmax: The Transaction ID of the transaction that deleted or updated this tuple version. For active, un-deleted rows, xmax remains 0 (or holds a row-level lock marker).
  • t_infomask / t_infomask2: Bit flags storing transaction status hints, indicating whether xmin or xmax has committed, aborted, or been frozen.

How DML Modifies Tuples Under MVCC

  1. INSERT: The engine writes a new tuple into a page, populating t_xmin with the current transaction's XID and setting t_xmax to 0.
  2. DELETE: PostgreSQL does not physically erase the row or clear the bytes on disk. It merely writes the deleting transaction's XID into the existing tuple's t_xmax field and sets commit flags once committed.
  3. UPDATE: An update is architecturally executed as a DELETE followed by an INSERT. PostgreSQL writes the current XID into the t_xmax field of the existing row version (marking it expired) and writes an entirely new tuple version into an available page block with t_xmin set to the current XID.

Because of this design, as soon as a transaction commits an UPDATE or DELETE, the old tuple version becomes a dead tuple (also called a dead row). As long as any concurrent transaction is active that started before the deletion committed, that transaction must still be able to read the old tuple version. Once all transactions that could possibly view the old tuple version have terminated, the dead tuple becomes completely invisible to all sessions. However, the dead tuple continues to occupy physical space inside the 8KB data page until it is reclaimed.


Dead Tuple Accumulation and the Anatomy of Bloat

If dead tuples are not periodically cleaned up, relations experience bloat—the accumulation of dead space inside table heap pages and index structures that contains no live data.

Impact of Bloat on Performance and Resources

  • Buffer Cache Pollution: When PostgreSQL reads data pages into shared_buffers, bloated pages consume memory cache space with useless dead tuples, evicting active data and lowering cache hit ratios.
  • Degraded Sequential Scans: A sequential scan must read every 8KB block allocated to a relation up to its high-water mark. If a table contains 100,000 live rows spread across 50,000 bloated blocks (where 10,000 blocks would suffice), a sequential scan performs 5x more disk I/O.
  • Index Bloat: When rows are updated, corresponding new index entries must be created in secondary indexes (unless the update qualifies for Heap-Only Tuple, or HOT, optimization). Dead index tuples accumulate in index leaf pages, widening B-Tree depth and increasing search latency.

The Standard VACUUM Command: Mechanics and Space Reuse

The standard VACUUM command is PostgreSQL's primary online mechanism for reclaiming dead tuple space during normal production operations.

-- Execute standard vacuum on a specific table
VACUUM customer_orders;

-- Execute standard vacuum with verbose output and query planner analysis
VACUUM (VERBOSE, ANALYZE) customer_orders;

Operational Phases of Standard VACUUM

  1. Heap Scan: The vacuum process scans the heap pages of the target relation, consulting the commit log (pg_xact) and active transaction snapshots to identify dead tuples whose xmax is older than the oldest active transaction.
  2. Index Vacuuming: If dead tuples are identified, vacuum traverses every index on the table, removing all index entries pointing to those dead heap tuples.
  3. Heap Pruning & Line Pointer Compaction: Vacuum revisits the heap pages, marks the dead tuple space as unallocated, and flags the corresponding line pointers (ItemIdData) as dead or unused.
  4. Map Updates: Vacuum updates two vital physical auxiliary structures: the Free Space Map (_fsm) and the Visibility Map (_vm).

Crucial Properties of Standard VACUUM

  • Non-Blocking Lock Level: Standard VACUUM acquires a SHARE UPDATE EXCLUSIVE lock on the relation. This lock mode conflicts only with schema alterations (ALTER TABLE), table drops (DROP TABLE), offline rewrites (VACUUM FULL, CLUSTER), and concurrent CREATE INDEX or VACUUM runs. It does NOT conflict with SELECT, INSERT, UPDATE, or DELETE. Applications can read and write to the table completely unimpeded while standard vacuum runs.
  • No Physical File Truncation (Disk Space Not Returned to OS): Standard VACUUM does not return freed disk space to the operating system filesystem! Instead, it records the newly emptied space inside the page in the table's Free Space Map (_fsm). When subsequent INSERT or UPDATE operations occur, PostgreSQL checks the _fsm and reuses those empty page slots for incoming tuples. The physical size of the table file on disk does not shrink (with the sole minor exception of completely empty pages residing at the absolute end of the relation file, which vacuum can truncate).

The Free Space Map (_fsm) and Visibility Map (_vm)

Every PostgreSQL table and index has two fork files residing alongside its main data file on disk:

1. Free Space Map (<relfilenode>_fsm)

Maintains a binary tree of available byte space across every 8KB page in the relation. When a backend executes an INSERT, it queries the _fsm to find a page with sufficient room for the new tuple. If no existing page has enough free space, PostgreSQL extends the table file by allocating a new 8KB block.

2. Visibility Map (<relfilenode>_vm)

A compact bitmap storing two critical status bits for each 8KB data page:

  • All-Visible Bit: Set when vacuum determines that every tuple on that data page is visible to all current and future transactions (i.e., the page contains zero dead tuples and no uncommitted writes). This allows the query optimizer to perform Index-Only Scans: if an index contains the required columns and the target heap page is marked all-visible, the engine does not need to visit the heap page at all, saving substantial disk I/O.
  • All-Frozen Bit: Set when every tuple on that page has been frozen by vacuum, indicating that the page does not need to be scanned during subsequent anti-wraparound vacuum passes.

VACUUM FULL: Offline Table Compaction and Space Reclamation

When a table suffers from severe bloat—such as after deleting 80% of rows from a 100-million-row table—standard VACUUM marks the space reusable for future writes, but cannot compact the live rows or return the disk storage to the underlying operating system. To physically reclaim disk space and shrink the file on disk, PostgreSQL provides VACUUM FULL.

-- Perform a complete physical rewrite of the table
VACUUM FULL customer_orders;

How VACUUM FULL Operates

  1. VACUUM FULL does not clean pages in place. Instead, it creates a completely new, pristine physical disk file for the table.
  2. It scans the old table file, reads only the live tuples, and writes them tightly packed into the new file.
  3. Once the live tuples are copied, it rebuilds all secondary indexes from scratch against the new file.
  4. It swaps the catalog file pointers (relfilenode) and unlinks (deletes) the old, bloated data file from the operating system.
  5. Unused disk space is immediately returned to the host operating system filesystem.

Operational Hazards and Locking

  • ACCESS EXCLUSIVE Lock: VACUUM FULL acquires the most restrictive lock in PostgreSQL: ACCESS EXCLUSIVE. This lock strictly blocks all concurrent queries, including read-only SELECT statements. Any application query attempting to access the table will block until VACUUM FULL completes.
  • Temporary Disk Space Requirements: Because VACUUM FULL constructs the new table file while the old file still exists, the filesystem must have sufficient free disk space to hold both copies of the table and all its indexes simultaneously.
Operational AttributeStandard VACUUMVACUUM FULL
Lock AcquiredSHARE UPDATE EXCLUSIVEACCESS EXCLUSIVE
Concurrent SELECT Allowed?Yes (Unimpeded reads)No (All queries blocked)
Concurrent INSERT/UPDATE/DELETE Allowed?Yes (Unimpeded writes)No (All writes blocked)
Returns Space to Operating System?No (Kept in _fsm for reuse)Yes (Shrinks file on disk)
Rewrites Table to New Disk File?No (In-place page cleanup)Yes (Complete file recreation)
Temporary Disk OverheadNegligibleRequires space for ~2x table size

[!TIP] Third-Party Alternative: In high-concurrency production environments where downtime or read locks cannot be tolerated, database administrators frequently utilize the community extension pg_repack. It accomplishes the same space reclamation and table compaction as VACUUM FULL online without holding an exclusive lock, using trigger-based change capture and an atomic catalog swap.


The Transaction ID (XID) Wraparound Hazard

PostgreSQL tracks transactional ordering using a 32-bit integer transaction counter (TransactionId). A 32-bit counter can represent approximately 4.29 billion (2^32) transactions.

The Circular Transaction Horizon

Because a production database could exhaust 4.29 billion transactions over several months or years of heavy write activity, transaction IDs are treated circularly using modulo-2^31 arithmetic:

                                  Current XID
                                       │
            Past (Visible)             │             Future (Invisible)
     ◄─────────────────────────────────┼─────────────────────────────────►
             ~2 Billion XIDs           │          ~2 Billion XIDs
  • Any XID within the 2 billion transactions prior to the current XID is considered in the past (visible to the current transaction, assuming it committed).
  • Any XID within the 2 billion transactions ahead of the current XID is considered in the future (invisible to the current transaction).

The Catastrophic Wraparound Failure

If a database continues processing transactions past 2.14 billion (2^31) without freezing historical rows, the circular calculation wraps around. Previously committed rows that had xmin = 100 would suddenly appear to be in the future relative to a current transaction counter at 2.15 billion, rendering historical enterprise data completely invisible! If the wraparound horizon approaches dangerously close, PostgreSQL enters a safety emergency mode and halts all write transactions with the error: ERROR: database is not accepting commands to avoid wraparound data loss in database "postgres".


Transaction Freezing and VACUUM FREEZE

To prevent transaction ID wraparound from corrupting visibility, PostgreSQL implements XID Freezing.

The Mechanics of Freezing

When VACUUM encounters a tuple whose t_xmin is older than vacuum_freeze_min_age (by default, 50 million transactions old), it marks the tuple as frozen:

  • Historically, PostgreSQL overwrote the tuple's t_xmin with a special reserved identifier: FrozenTransactionId (value 2).
  • In modern PostgreSQL, the engine sets the HEAP_XMIN_FROZEN bit in the tuple's header flags (t_infomask).
  • A tuple with the frozen flag is recognized by the visibility engine as permanently committed and older than all past and future transactions. Its age is no longer evaluated using circular modulo comparison, immunizing it from wraparound.
-- Force an aggressive vacuum pass that freezes all eligible tuples
VACUUM (FREEZE) customer_orders;

The Autovacuum Daemon: Architecture and Trigger Formulas

PostgreSQL includes the autovacuum daemon, an automated background subsystem composed of two process types:

  1. Autovacuum Launcher: A persistent background coordinator process started by the postmaster. It periodically awakens (governed by autovacuum_naptime, default 1 minute) and inspects table activity statistics collected in shared memory.
  2. Autovacuum Workers: Short-lived worker processes spawned by the launcher (up to autovacuum_max_workers, default 3) to execute VACUUM and ANALYZE operations on relations requiring maintenance.
+-------------------------------------------------------------------------+
|                        Autovacuum Architecture                          |
+-------------------------------------------------------------------------+
|  Autovacuum Launcher (Wakes every autovacuum_naptime)                   |
|         │                                                               |
|         ├──> Checks table modification metrics in statistics cache      |
|         │                                                               |
|         └──> Spawns Worker Processes (Up to autovacuum_max_workers)     |
|                   │                           │                         |
|                   ▼                           ▼                         |
|          [Autovacuum Worker 1]       [Autovacuum Worker 2]              |
|          Runs VACUUM on Table A      Runs ANALYZE on Table B            |
+-------------------------------------------------------------------------+

The Vacuum Trigger Formula

An autovacuum worker is triggered on a relation when the number of dead tuples exceeds the calculated threshold:

Dead Tuples Threshold = autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor * n_live_tuples)

  • autovacuum_vacuum_threshold: Minimum number of dead tuples required to trigger vacuum (default: 50).
  • autovacuum_vacuum_scale_factor: Fraction of the table size in live tuples added to the threshold (default: 0.2, or 20%).

Example: A table with 1,000,000 live rows will trigger an autovacuum run once dead tuples reach: 50 + (0.20 * 1,000,000) = 200,050 dead tuples

The Analyze Trigger Formula

Similarly, autovacuum triggers statistical sampling via ANALYZE when inserts, updates, or deletes exceed:

Modifications Threshold = autovacuum_analyze_threshold + (autovacuum_analyze_scale_factor * n_live_tuples)

  • Defaults: autovacuum_analyze_threshold = 50, autovacuum_analyze_scale_factor = 0.1 (10%).

Emergency Anti-Wraparound Vacuuming

Even if autovacuum is disabled globally (autovacuum = off), PostgreSQL will forcibly spawn autovacuum workers if a table's oldest unfrozen XID exceeds autovacuum_freeze_max_age (default: 200,000,000 transactions). This mandatory anti-wraparound vacuum scans the table, freezes historical tuples, and advances the catalog cutoff in pg_class.relfrozenxid to preserve cluster viability.

Core Autovacuum Configuration Parameters

Configuration ParameterDefault ValueTuning Guidance
autovacuumonNever disable globally in production.
autovacuum_max_workers3Increase to 4-8 on multi-core servers with many active tables.
autovacuum_naptime1minHow often the launcher checks statistics.
autovacuum_vacuum_scale_factor0.2 (20%)On large tables (e.g. 50M rows), 20% is 10M dead tuples! Lower to 0.05 or 0.02 per table.
autovacuum_vacuum_cost_limit-1 (falls back to vacuum_cost_limit = 200)Increase to 1000–2000 on fast NVMe SSD storage to allow vacuum to finish faster.
autovacuum_vacuum_cost_delay2msCost throttling sleep time when cost limit is hit.
autovacuum_freeze_max_age200000000Maximum age of unfrozen XIDs before forcing aggressive anti-wraparound vacuum.

Practical Administrative Workflows and Monitoring

Database administrators can inspect dead tuple accumulation and monitor autovacuum progress using PostgreSQL's system views:

-- Inspect dead tuples, live tuples, and last vacuum timestamps
SELECT 
    relname,
    n_live_tup,
    n_dead_tup,
    ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_tuple_pct,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

-- Monitor currently active vacuum operations in real time
SELECT 
    pid,
    phase,
    heap_blks_total,
    heap_blks_scanned,
    heap_blks_vacuumed,
    index_vacuum_count
FROM pg_stat_progress_vacuum;

-- Tune autovacuum on a high-throughput table individually
ALTER TABLE customer_orders SET (
    autovacuum_vacuum_scale_factor = 0.05,
    autovacuum_vacuum_threshold = 1000
);

Exam Tips and Common Pitfalls

  • Exam Trap: Disk Space Reclamation: Remember that standard VACUUM does NOT return disk space to the operating system. It updates the Free Space Map (_fsm) so that subsequent inserts reuse the space. Only VACUUM FULL shrinks the physical file on disk (at the expense of an ACCESS EXCLUSIVE lock).
  • Exam Trap: Locks and Concurrency: Standard VACUUM uses SHARE UPDATE EXCLUSIVE, which allows concurrent SELECT, INSERT, UPDATE, and DELETE. In contrast, VACUUM FULL takes an ACCESS EXCLUSIVE lock, which blocks all reads and writes.
  • Exam Trap: Transaction Wraparound Failsafe: Can autovacuum be completely turned off to prevent anti-wraparound vacuums? No. Even if autovacuum = off is set in postgresql.conf, the engine will ignore this setting and launch emergency worker processes once autovacuum_freeze_max_age is breached to prevent data loss.
Loading diagram...
MVCC Tuple Lifecycle and Space Reclamation Mechanics
Test Your Knowledge

A production database administrator notices that a 200GB transaction table contains approximately 60GB of dead tuples after a massive data cleanup script. The application requires continuous 24/7 read and write availability. Which administrative action should the administrator take?

A
B
C
D
Test Your Knowledge

Why does PostgreSQL enforce transaction ID (XID) freezing via VACUUM FREEZE, and what would happen if a high-write cluster ran continuously without freezing historical tuples?

A
B
C
D
Test Your Knowledge

An administrator examines a PostgreSQL table with 2,000,000 live rows. The cluster configuration has autovacuum_vacuum_threshold set to 100 and autovacuum_vacuum_scale_factor set to 0.10. How many dead tuples must accumulate before an autovacuum worker is triggered on this relation?

A
B
C
D