Connect MVCC dead versions to pruning, reusable free space, visibility-map maintenance, and index-only scans using evidence rather than file-size folklore.

Why VACUUM Exists: Dead Tuple Reclamation and Visibility Maintenance

Run a guided insert/update/delete/vacuum story with pageinspect and visibility diagnostics, correlate line pointers and tuple flags with SQL-visible state, and define the boundary between diagnostics and application interfaces.

Intermediate → Advanced160–210 minutesEvidence-driven maintenance labCurrent patched PostgreSQL 18.xCore PostgreSQL; supplied diagnostic extensions only where labeledLocal table owner/admin privileges as indicatedNo paid or managed-service dependencyLast reviewed: August 2026

Learning outcomes

ServiceHub has an update-heavy work-order feed. The team notices that the table file is larger than the number of currently visible rows would suggest, and someone proposes a nightly VACUUM FULL. This lesson starts from the actual PostgreSQL mechanism: Multi-Version Concurrency Control (MVCC) leaves obsolete tuple versions behind until they are no longer needed; ordinary VACUUM turns eligible dead space into reusable space, maintains visibility metadata, and protects transaction-ID health. That is a different job from rewriting a table to make its operating-system file smaller.

01

Explain why MVCC creates obsolete tuple versions and why PostgreSQL cannot immediately overwrite every old version.

02

Measure live/dead-tuple estimates, relation sizes, vacuum counters, and visibility state before and after controlled churn.

03

Demonstrate that ordinary VACUUM usually makes space reusable without promising whole-file shrinkage.

04

Connect all-visible pages to index-only scan heap-fetch behavior without confusing planner choice with correctness.

05

Choose routine VACUUM from evidence rather than using rewrite operations as preventive folklore.

Mental model

A dead tuple is not automatically wasted forever. It is a version that is no longer visible to any transaction and can eventually be reclaimed. VACUUM is primarily a reuse/visibility/freeze maintenance process; file truncation at the physical tail can happen, but whole-table compaction is not its normal contract.

1. Create a disposable high-churn ServiceHub table

The lab deliberately uses a separate app.ch09_vacuum_lab table so maintenance experiments cannot damage earlier course objects. Run the setup as a role that owns the table or has the required maintenance privileges.

sql · setup and baseline
DROP TABLE IF EXISTS app.ch09_vacuum_lab;CREATE TABLE app.ch09_vacuum_lab (    work_order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    customer_id integer NOT NULL,    status text NOT NULL CHECK (status IN ('queued','assigned','done','cancelled')),    payload text NOT NULL,    updated_at timestamptz NOT NULL DEFAULT clock_timestamp());CREATE INDEX ch09_vacuum_status_id_idx    ON app.ch09_vacuum_lab(status, work_order_id);INSERT INTO app.ch09_vacuum_lab(customer_id, status, payload)SELECT (g % 500) + 1,       CASE WHEN g % 5 = 0 THEN 'done' ELSE 'queued' END,       repeat(md5(g::text), 4)FROM generate_series(1, 20000) AS g;ANALYZE app.ch09_vacuum_lab;

ANALYZE samples the table and refreshes planner statistics; it does not remove dead tuples. Record both estimated row-state counters and physical sizes before the churn.

sql · supported baseline evidence
SELECT relname, n_live_tup, n_dead_tup,       last_vacuum, vacuum_count,       last_autovacuum, autovacuum_count,       last_analyze, analyze_countFROM pg_stat_user_tablesWHERE relid = 'app.ch09_vacuum_lab'::regclass;SELECT pg_size_pretty(pg_relation_size('app.ch09_vacuum_lab')) AS heap,       pg_size_pretty(pg_indexes_size('app.ch09_vacuum_lab')) AS indexes,       pg_size_pretty(pg_total_relation_size('app.ch09_vacuum_lab')) AS total;

n_live_tup and n_dead_tup are estimates maintained by the statistics system, not a byte-accurate heap inventory. Size functions answer a different question: how much relation storage is currently allocated.

2. Manufacture obsolete versions safely

Updates create new tuple versions; deletes make the old version logically dead after the deleting transaction commits and once no older snapshot still needs it. We intentionally perform both.

sql · controlled churn
UPDATE app.ch09_vacuum_labSET status = 'assigned',    updated_at = clock_timestamp()WHERE work_order_id % 3 = 0;DELETE FROM app.ch09_vacuum_labWHERE work_order_id % 4 = 0;SELECT pg_stat_clear_snapshot();SELECT n_live_tup, n_dead_tup, n_tup_upd, n_tup_delFROM pg_stat_user_tablesWHERE relid = 'app.ch09_vacuum_lab'::regclass;

Exact counter values can lag because statistics are accumulated asynchronously and may be cached within your session. The invariant is the direction of change: SQL sees fewer current rows, while obsolete versions can remain physically present until cleanup is possible.

sql · logical count is not a bloat meter
SELECT count(*) AS visible_rowsFROM app.ch09_vacuum_lab;SELECT pg_size_pretty(pg_relation_size('app.ch09_vacuum_lab')) AS heap_after_churn;
Wrong inference

If visible row count falls while pg_relation_size stays similar, that does not prove corruption and it does not automatically justify VACUUM FULL. It is normal for PostgreSQL to keep allocated pages so future inserts/updates can reuse them.

3. Run ordinary VACUUM and interpret the evidence

VACUUM (ANALYZE) combines tuple cleanup/visibility work with planner-statistics refresh. It is online relative to ordinary reads and writes, although it still takes lightweight relation locks and consumes I/O/CPU.

sql · vacuum and re-measure
VACUUM (ANALYZE, VERBOSE) app.ch09_vacuum_lab;SELECT pg_stat_clear_snapshot();SELECT n_live_tup, n_dead_tup,       last_vacuum, vacuum_count,       last_analyze, analyze_countFROM pg_stat_user_tablesWHERE relid = 'app.ch09_vacuum_lab'::regclass;SELECT pg_size_pretty(pg_relation_size('app.ch09_vacuum_lab')) AS heap_after_vacuum;

After a successful vacuum, the dead-tuple estimate commonly falls and the manual-vacuum counters/timestamps advance. The heap file may stay near the same size because pages are now reusable inside the relation. PostgreSQL can sometimes truncate completely empty pages from the physical tail, so “VACUUM never shrinks a file” is also too absolute.

4. Visibility map and index-only scans

The visibility map tracks heap pages that are all-visible (and separately all-frozen). An index-only scan can return indexed columns without visiting the heap only when PostgreSQL can establish that the referenced heap page is all-visible. VACUUM is an important producer/maintainer of those bits.

sql · optional supplied visibility diagnostic
CREATE EXTENSION IF NOT EXISTS pg_visibility;SELECT *FROM pg_visibility_map_summary('app.ch09_vacuum_lab'::regclass);

pg_visibility is a supplied extension; installing it may require elevated privileges. The mandatory mental model does not depend on it, but the summary makes otherwise-hidden visibility metadata observable.

sql · mechanism-oriented plan check
BEGIN;SET LOCAL enable_seqscan = off;EXPLAIN (ANALYZE, BUFFERS)SELECT work_order_id, statusFROM app.ch09_vacuum_labWHERE status = 'done'ORDER BY work_order_idLIMIT 100;ROLLBACK;

The lab-only planner toggle helps expose the index-only mechanism; it is not a production tuning recommendation. If the plan contains Index Only Scan, inspect Heap Fetches. Fewer heap fetches after vacuum can be consistent with more all-visible pages, but a single plan is not a universal performance guarantee.

5. When VACUUM cannot reclaim a version yet

VACUUM is constrained by visibility horizons. A long-running transaction can keep an old snapshot alive, so tuple versions that are dead to newer transactions may still be potentially visible to that older snapshot. This is why “autovacuum ran” and “all obsolete versions became reusable” are not synonymous.

sql · look for old snapshots and transaction age
SELECT pid, usename, state, xact_start, backend_xmin, wait_event_type, wait_event, queryFROM pg_stat_activityWHERE datname = current_database()ORDER BY xact_start NULLS LAST;SELECT pid, relid::regclass, phase,       heap_blks_total, heap_blks_scanned, heap_blks_vacuumedFROM pg_stat_progress_vacuum;

backend_xmin is evidence that a backend contributes to a snapshot horizon, while xact_start helps identify transactions that have remained open. Do not terminate sessions merely because they are old: first identify application ownership, correctness impact, and whether that backend actually explains the maintenance blockage.

6. Wrong approach: “VACUUM FULL every night”

VACUUM FULL rewrites a table into a new compact file and requires an ACCESS EXCLUSIVE lock. That can be appropriate for exceptional space-reclamation cases, but it is not a substitute for healthy autovacuum.

sql · diagnose before rewrite
SELECT n_live_tup, n_dead_tup,       last_autovacuum, autovacuum_countFROM pg_stat_user_tablesWHERE relid = 'app.ch09_vacuum_lab'::regclass;SELECT pg_size_pretty(pg_relation_size('app.ch09_vacuum_lab')),       pg_size_pretty(pg_indexes_size('app.ch09_vacuum_lab'));

The repaired workflow asks: is there reusable space, is the table actually growing despite reuse, is autovacuum keeping up, are long transactions blocking cleanup, and is returning disk to the OS genuinely required? Chapter 09 Lesson 5 will compare rewrite choices after those questions are answered.

7. Production judgment and cleanup

Routine vacuum health should be monitored using a combination of dead/live estimates, last vacuum/autovacuum times, table growth, long-running transactions, visibility/index-only behavior when relevant, and autovacuum logs/progress during incidents. No single counter is a bloat percentage. Do not schedule VACUUM FULL merely because a heap file does not shrink after deletes.

sql · cleanup
DROP TABLE IF EXISTS app.ch09_vacuum_lab;

Check your understanding

  1. Why can a table file remain large after ordinary VACUUM even when n_dead_tup falls?
  2. What is the visibility map used for in the index-only-scan story?
  3. Why is n_dead_tup not a byte-accurate bloat measurement?
  4. What is wrong with treating VACUUM FULL as routine autovacuum replacement?
  5. Why can the statement “ordinary VACUUM never shrinks a file” also be misleading?
Review the answers

Ordinary VACUUM normally makes relation space reusable rather than rewriting the file. The visibility map records pages that are safe to treat as all-visible/all-frozen, enabling heap avoidance for index-only scans. Statistics counters are estimates, not page inventories. VACUUM FULL is a blocking rewrite with different operational cost. Ordinary vacuum can occasionally truncate empty tail pages, so its normal behavior should be described precisely rather than absolutely.

Authoritative references

These mechanisms are version-sensitive. Use the documentation for the PostgreSQL major you actually operate.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.