July 7, 2026

Under the Hood: A Byte-by-Byte Anatomy of PostgreSQL VACUUM

under-the-hood-a-byte-by-byte-anatomy-of-postgresql-vacuum

under-the-hood-a-byte-by-byte-anatomy-of-postgresql-vacuum

In the realm of database management, few operations are as misunderstood or as critical as PostgreSQL’s VACUUM. While modern PostgreSQL features like "HOT Updates" (Heap-Only Tuples) provide an elegant, lightweight shortcut for reclaiming space during standard read operations without invoking background processes, they are strictly limited. They function only within the confines of a single page and only for specific types of updates. For the vast majority of database maintenance—including cold updates that touch indexed columns, standard DELETE operations, index entry cleanup, and visibility map maintenance—PostgreSQL relies on the VACUUM process.

This article moves beyond the operational overview of autovacuum tuning and worker allocation to provide an empirical, byte-by-byte examination of how VACUUM actually transforms a page. By utilizing diagnostic tools such as pageinspect, pg_visibility, and pg_freespacemap, we can witness the exact state transitions of page headers, line pointers, and tuple metadata.

The Mechanics of a Clean Slate

To understand the transformation, we first establish a controlled environment. We created a table, vacuum_demo, populated with 50 rows, each carrying a 100-byte payload. By including a primary key, we force PostgreSQL to maintain an index, which fundamentally changes how VACUUM interacts with the heap.

A fresh VACUUM run leaves our baseline page in a pristine state. The page header reports pd_lower at 224 bytes—the standard header plus 50 line pointers—and pd_upper at 1392 bytes. The resulting 1,168 bytes of free space represents the "active" footprint. At this stage, every line pointer is flagged as LP_NORMAL, and every tuple resides in its original position with a t_xmax of 0, signaling that the records are untouched.

The Anatomy of a Delete

When we issue a DELETE command targeting approximately one-third of the rows, the immediate impact on the physical storage is deceptive. While the database confirms the deletion of 16 rows, the page header remains identical to its pre-delete state. pd_lower and pd_upper do not shift.

PostgreSQL handles this by stamping the t_xmax field of the affected tuples with the ID of the deleting transaction. Crucially, the tuples remain physically present on the page. They are now "dead" to all concurrent transactions, but they occupy the same space and retain the same line pointer status (LP_NORMAL). Without VACUUM, this bloat remains stagnant. The data is effectively invisible, yet it continues to consume disk I/O and memory resources.

The Three-Phase Cleanup Protocol

VACUUM does not perform its duties in a single sweep; it operates in three distinct phases, a design choice necessitated by the existence of indexes.

Phase 1: Heap Scanning and Pruning

During the first pass, VACUUM performs a sequential scan of the heap. It uses the heap_page_prune_and_freeze machinery to reclaim space. Here, the tuple data is stripped, the page is defragmented, and pd_upper is advanced. While the storage for the tuple is reclaimed, the line pointer cannot yet be marked as LP_UNUSED because indexes still reference the tuple’s Tuple Identifier (TID). Instead, the line pointer is set to LP_DEAD.

Phase 2: Index Cleanup

With the list of LP_DEAD line pointers in hand, VACUUM turns to the indexes. It must traverse every index page to remove entries pointing to these now-defunct TIDs. This phase is computationally expensive and is the primary driver of index bloat. If a long-running transaction prevents VACUUM from completing this step, index entries accumulate, leading to performance degradation.

Phase 3: Heap Cleanup

Only after the indexes are purged can VACUUM revisit the heap to finalize the process. It flips the LP_DEAD pointers to LP_UNUSED. The line pointer slots are now available for reuse. This "two-pass" dance—pruning the heap, cleaning the indexes, then finalizing the heap—is why deleted tuple storage and the line pointers themselves vanish at different moments.

Supporting Data: The Lifecycle of a Line Pointer

The transition states of line pointers provide a precise roadmap of the vacuuming process:

  • LP_UNUSED (0): The slot is empty. It is ready for a new insertion.
  • LP_NORMAL (1): The slot is active. The tuple is either live or currently being checked for visibility.
  • LP_REDIRECT (2): A byproduct of HOT pruning, pointing to a newer version of the tuple within the same page.
  • LP_DEAD (3): The "limbo" state. Storage has been reclaimed, but the slot is reserved until index cleanup completes.

Our testing confirms that on a table without indexes, the transition is binary: LP_NORMAL jumps directly to LP_UNUSED in a single pass. The complexity observed in our demo is an architectural requirement to maintain referential integrity between the heap and the B-Tree indexes.

Implications for Database Maintenance

The visibility map (VM) is the unsung hero of this process. It tracks whether all tuples on a page are visible to all transactions. When we perform our final VACUUM, the all_visible bit is set. This allows the database to skip heap fetches during index-only scans, significantly reducing I/O.

Furthermore, the "freeze" status is critical for long-term health. By marking tuples as frozen, PostgreSQL informs the engine that these rows will not be affected by Transaction ID (XID) wraparound. In modern versions, this is managed through t_infomask bits, allowing the system to maintain the original t_xmin for auditability while signaling that the row is permanently valid.

VACUUM FULL vs. Regular VACUUM

A common point of contention is whether VACUUM can shrink a table. The answer is nuanced:

  1. Regular VACUUM: It only truncates the file if the trailing pages are entirely empty. It cannot move data to fill "holes" in the middle of a file. It is lightweight, non-blocking, and designed for steady-state maintenance.
  2. VACUUM FULL: This command performs a full rewrite of the table, compacting data into a new file and releasing all internal free space. However, it requires an AccessExclusiveLock, which halts all read and write activity.

For production systems, the consensus is clear: prioritize regular VACUUM to keep the Free Space Map (FSM) updated, allowing the database to reuse internal gaps. Use VACUUM FULL (or online tools like pg_repack) only as an emergency measure when internal fragmentation significantly impacts query performance.

Conclusion

The VACUUM process is the heartbeat of PostgreSQL’s MVCC architecture. By understanding the transition from LP_NORMAL to LP_DEAD and finally LP_UNUSED, administrators can better diagnose bloat and understand why tables behave the way they do on disk. While the process is automated, its visibility—via the FSM, the Visibility Map, and index statistics—is a powerful diagnostic tool for ensuring a high-performance, healthy database cluster. The ability to monitor these byte-level changes confirms that VACUUM is not just a cleaning task, but a sophisticated piece of storage engineering designed to maintain the integrity and efficiency of the entire system.