Vacuum
MVCC and Dead Tuples
When someone asks why their Postgres table is 10x larger than the actual data it holds, the answer almost always starts here: MVCC.
Postgres uses Multi-Version Concurrency Control to let readers and writers operate simultaneously without blocking each other. The mechanism is elegant — every UPDATE creates a brand-new row version, and every DELETE marks a row as invisible rather than actually removing it. Concurrent transactions that started before the change can still see the old version. Nobody waits for a lock.
The tradeoff? Those old row versions — called dead tuples — stick around until vacuum cleans them up.
Here's how dead tuples accumulate:
-- A simple UPDATE creates a dead tuple UPDATE orders SET status = 'shipped' WHERE order_id = 42; -- The old row (status = 'pending') is now dead but still physically on disk -- A DELETE also creates a dead tuple DELETE FROM sessions WHERE expires_at < now(); -- Those rows are invisible but still consuming space
To see how many dead tuples are piling up across your tables:
SELECT schemaname || '.' || relname AS table_name, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct, last_autovacuum, last_vacuum FROM pg_stat_user_tables WHERE n_dead_tup > 0 ORDER BY n_dead_tup DESC LIMIT 15;
Example output on a busy system:
table_name | n_live_tup | n_dead_tup | dead_pct | last_autovacuum -----------------------+------------+------------+----------+--------------------------- public.events | 4823901 | 892410 | 15.6 | 2025-01-15 03:22:14+00 public.sessions | 312044 | 187220 | 37.5 | 2025-01-15 03:18:01+00 public.audit_log | 1205890 | 54320 | 4.3 | 2025-01-15 02:45:33+00 public.orders | 890122 | 12045 | 1.3 | 2025-01-15 03:20:55+00
That sessions table at 37.5% dead tuples? That's a problem. It means more than a third of the physical storage is occupied by invisible garbage, and sequential scans have to skip over all of it.
Dead tuples cause three concrete problems:
- Wasted disk space — the table file grows without the live data growing
- Slower sequential scans — Postgres must read dead tuples from disk even though they're invisible
- Index bloat — indexes still point to dead tuples until vacuum removes the entries
How Vacuum Works
Regular VACUUM is Postgres's garbage collector. It scans a table, identifies dead tuples that no running transaction needs anymore, and marks that space as reusable for future inserts and updates. Critically, it does NOT return space to the operating system — the table file stays the same size on disk, but the space inside becomes available.
The key properties of regular VACUUM:
- Does not lock the table — reads and writes continue normally
- Does not shrink the file — reclaimed space is reused internally
- Freezes old transaction IDs — prevents wraparound (more on this later)
- Updates the visibility map — enables index-only scans
-- Run vacuum on a specific table VACUUM orders; -- Vacuum and update planner statistics in one pass VACUUM ANALYZE orders; -- See exactly what vacuum is doing VACUUM VERBOSE orders;
The VERBOSE output tells you exactly what happened:
INFO: vacuuming "public.orders" INFO: table "orders": found 12045 removable, 890122 nonremovable row versions in 8823 out of 9150 pages DETAIL: 0 dead row versions cannot be removed yet, oldest xmin: 4582901 12045 rows are dead but not yet removable CPU: user: 0.18 s, system: 0.05 s, elapsed: 0.42 s
This tells you vacuum found and reclaimed 12,045 dead tuples across 8,823 pages. The table is 9,150 pages total, so vacuum scanned most of it.
HOT Updates: Reducing the Vacuum Workload
Postgres has an optimization called HOT (Heap Only Tuple) updates. When an UPDATE modifies only non-indexed columns and the new tuple fits on the same heap page, Postgres can avoid creating a new index entry entirely. The old tuple is marked with a redirect pointer to the new one.
HOT updates are significant because they're cleaned up during normal page access — they don't always need a full vacuum pass. You can encourage HOT updates by setting a table's fill factor below 100:
-- Reserve 20% of each page for HOT updates ALTER TABLE orders SET (fillfactor = 80); -- Check HOT update ratio for your tables SELECT schemaname || '.' || relname AS table_name, n_tup_upd, n_tup_hot_upd, round(n_tup_hot_upd::numeric / nullif(n_tup_upd, 0) * 100, 1) AS hot_pct FROM pg_stat_user_tables WHERE n_tup_upd > 100 ORDER BY n_tup_upd DESC LIMIT 10;
table_name | n_tup_upd | n_tup_hot_upd | hot_pct --------------------+-----------+---------------+--------- public.sessions | 2841022 | 2105600 | 74.1 public.orders | 654890 | 412300 | 63.0 public.users | 123456 | 98700 | 79.9
A hot_pct above 70% is excellent. For update-heavy tables where you're primarily changing non-indexed columns, setting fillfactor to 70-90 can dramatically reduce vacuum workload and index bloat.
Autovacuum Configuration
Like most engineering-related questions, the answer to "how often should vacuum run?" is: it depends. That's why Postgres has autovacuum — a background process that triggers vacuum automatically based on table activity.
The Trigger Formula
Autovacuum launches a vacuum when:
dead_tuples > autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor * n_live_tup)
With the defaults (threshold = 50, scale_factor = 0.2):
| Table Size (live rows) | Vacuum Triggers After Dead Tuples |
|---|---|
| 1,000 | 250 |
| 10,000 | 2,050 |
| 100,000 | 20,050 |
| 1,000,000 | 200,050 |
| 10,000,000 | 2,000,050 |
See the problem? For a 10M-row table, autovacuum waits until 2 million dead tuples pile up. That's often too late — the table has already bloated significantly.
Tuning for High-Churn Tables
For large, frequently-updated tables, lower the scale factor:
-- Trigger vacuum after 1% dead tuples instead of 20% ALTER TABLE events SET ( autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 1000 ); -- For a table with 10M rows, this triggers at ~101,000 dead tuples -- instead of the default ~2,000,050
Cost-Based Throttling
Autovacuum throttles itself to avoid overwhelming I/O. The key settings:
-- Show current autovacuum cost settings SELECT name, setting, short_desc FROM pg_settings WHERE name LIKE 'autovacuum%' ORDER BY name;
name | setting | short_desc ----------------------------------+---------+--------------------------------------------- autovacuum | on | Starts the autovacuum subprocess. autovacuum_max_workers | 3 | Sets the maximum number of autovacuum processes. autovacuum_naptime | 60 | Time between autovacuum runs. autovacuum_vacuum_cost_delay | 2 | Cost delay for autovacuum (ms). autovacuum_vacuum_cost_limit | -1 | Cost limit for autovacuum (-1 uses vacuum_cost_limit). autovacuum_vacuum_scale_factor | 0.2 | Fraction of table size before autovacuum. autovacuum_vacuum_threshold | 50 | Minimum number of dead tuples before autovacuum.
On systems with fast storage (SSDs, NVMe), you can often make autovacuum more aggressive:
-- Per-table: let autovacuum work harder on this table ALTER TABLE events SET ( autovacuum_vacuum_cost_delay = 0, autovacuum_vacuum_cost_limit = 10000 );
Setting cost_delay to 0 removes the sleep between vacuum I/O operations. This makes vacuum faster but uses more I/O bandwidth. On modern SSDs this is usually fine — on spinning disks, be more conservative.
aside positive Snowflake Postgres tunes autovacuum automatically based on your workload patterns. The platform monitors table bloat, dead tuple ratios, and transaction ID age — adjusting vacuum aggressiveness without manual intervention. You can observe vacuum activity through Snowflake Postgres Insights.
VACUUM FULL vs Regular VACUUM
This is where people get confused. Regular VACUUM marks dead space as reusable within the table file. VACUUM FULL rewrites the entire table to a new file, physically compacting it and releasing space to the OS.
| Regular VACUUM | VACUUM FULL | |
|---|---|---|
| Locks table? | No (concurrent reads/writes OK) | Yes — ACCESS EXCLUSIVE lock |
| Shrinks file on disk? | No | Yes |
| How long? | Seconds to minutes | Minutes to hours (proportional to table size) |
| Safe for production? | Yes — run it constantly | Rarely — blocks all access |
| Space requirement | Minimal | Needs free disk space equal to table size |
-- Regular vacuum: safe, non-blocking VACUUM orders; -- VACUUM FULL: blocks everything, use only when desperate VACUUM FULL orders; -- All queries against 'orders' will block until this completes!
When to Consider VACUUM FULL
In most production environments, you should never need VACUUM FULL. Regular vacuum keeps pace with normal workloads. Consider VACUUM FULL only when:
- A table has extreme bloat (50%+ dead space) from a one-time bulk delete
- You've verified with
pgstattuplethat the bloat is real, not just a statistics artifact - You can tolerate downtime on that table
-- Check actual bloat with pgstattuple CREATE EXTENSION IF NOT EXISTS pgstattuple; SELECT table_len, tuple_count, dead_tuple_count, round(dead_tuple_len::numeric / table_len * 100, 1) AS dead_pct, round(free_space::numeric / table_len * 100, 1) AS free_pct FROM pgstattuple('orders');
table_len | tuple_count | dead_tuple_count | dead_pct | free_pct ------------+-------------+------------------+----------+---------- 7495680000 | 890122 | 12045 | 0.1 | 42.3
That 42.3% free_pct means vacuum already reclaimed space internally — it's reusable. This table doesn't need VACUUM FULL.
Alternatives to VACUUM FULL
For tables that genuinely need physical compaction without downtime, consider pg_repack:
-- pg_repack: online table rewrite without exclusive locks -- (run from command line, not SQL — requires extension installed) -- pg_repack --table orders --no-superuser-check -d mydb
pg_repack creates a new copy of the table in the background, replays changes, then swaps in an instant lock-swap operation. Much safer for production.
Transaction ID Wraparound
This is the scenario that keeps DBAs up at night. Postgres assigns a 32-bit transaction ID (txid) to every write transaction. That's approximately 4.2 billion IDs, with a circular comparison where about 2 billion are "in the past" and 2 billion are "in the future."
If a table has unfrozen transaction IDs that are approaching 2 billion transactions old, those transactions could wrap around and suddenly appear to be "in the future" — making committed data invisible. To prevent data corruption, Postgres will shut itself down entirely rather than let this happen.
This is not theoretical — it happens in production when vacuum can't keep up.
How Vacuum Prevents Wraparound
During a normal vacuum pass, Postgres freezes old transaction IDs — replacing the specific txid with a special "frozen" marker that means "definitely in the past, visible to everyone." This resets the clock on that tuple.
-- Check transaction ID age for each database SELECT datname, age(datfrozenxid) AS xid_age, round(age(datfrozenxid)::numeric / 2000000000 * 100, 1) AS pct_to_wraparound FROM pg_database ORDER BY age(datfrozenxid) DESC;
datname | xid_age | pct_to_wraparound ------------+------------+------------------- analytics | 189234012 | 9.5 production | 45002341 | 2.3 template1 | 45002300 | 2.3
The Danger Thresholds
| xid_age | Situation |
|---|---|
| < 50M | Healthy — nothing to worry about |
| 50M-150M | Normal — autovacuum handles this fine |
| 150M-200M | Approaching aggressive threshold |
| > 200M | autovacuum_freeze_max_age triggers aggressive vacuum |
| > 1.2B | WARNING territory — investigate immediately |
| > 2B | Database shutdown imminent |
When age(datfrozenxid) exceeds autovacuum_freeze_max_age (default: 200 million), Postgres launches an aggressive autovacuum that scans every page in the table — even those marked as all-visible in the visibility map. This is more expensive than regular autovacuum but necessary to freeze old txids.
-- Find tables closest to triggering aggressive vacuum SELECT schemaname || '.' || relname AS table_name, age(relfrozenxid) AS xid_age, pg_size_pretty(pg_total_relation_size(oid)) AS total_size FROM pg_class WHERE relkind = 'r' AND age(relfrozenxid) > 100000000 ORDER BY age(relfrozenxid) DESC LIMIT 10;
What Blocks Vacuum from Freezing
The most common cause of wraparound emergencies? Long-running transactions. Vacuum cannot freeze any transaction ID newer than the oldest active transaction in the system.
-- Find long-running transactions that may block vacuum SELECT pid, now() - xact_start AS xact_duration, state, left(query, 60) AS query_preview FROM pg_stat_activity WHERE xact_start IS NOT NULL AND state != 'idle' ORDER BY xact_start ASC LIMIT 5;
An idle-in-transaction connection that's been open for hours — or days — can single-handedly prevent vacuum from doing its job across the entire database.
Monitoring Vacuum Activity
In production, you don't run vacuum manually — you monitor autovacuum and intervene when it falls behind. Here are the queries that matter.
Currently Running Vacuums
-- What's vacuum doing right now? SELECT p.pid, a.query, p.datname, p.relid::regclass AS table_name, p.phase, p.heap_blks_total, p.heap_blks_scanned, round(p.heap_blks_scanned::numeric / nullif(p.heap_blks_total, 0) * 100, 1) AS pct_done, p.num_dead_tuples FROM pg_stat_progress_vacuum p JOIN pg_stat_activity a ON a.pid = p.pid;
pid | table_name | phase | heap_blks_total | heap_blks_scanned | pct_done | num_dead_tuples -------+--------------------+----------------+-----------------+-------------------+----------+----------------- 14523 | public.events | scanning heap | 1203450 | 892100 | 74.1 | 234501
Tables That Need Attention
-- Tables where autovacuum is falling behind SELECT schemaname || '.' || relname AS table_name, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct, last_autovacuum, last_autoanalyze, autovacuum_count, pg_size_pretty(pg_total_relation_size(relid)) AS total_size FROM pg_stat_user_tables WHERE n_dead_tup > 10000 AND round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) > 10 ORDER BY n_dead_tup DESC;
When to Worry
These are the warning signs that vacuum needs attention:
| Signal | Severity | Action |
|---|---|---|
dead_pct > 20% on large tables | Medium | Check if autovacuum is running, reduce scale_factor |
| Autovacuum running constantly but not keeping up | High | Increase autovacuum_max_workers, reduce cost_delay |
age(datfrozenxid) > 500M | High | Check for long-running transactions, run manual vacuum freeze |
age(datfrozenxid) > 1B | Critical | Emergency — may need to cancel blocking queries and vacuum immediately |
| Table size growing while live rows stay flat | Medium | Bloat accumulating — check vacuum effectiveness |
A Comprehensive Health Check
-- One query to rule them all: overall vacuum health WITH vacuum_stats AS ( SELECT schemaname || '.' || relname AS table_name, pg_total_relation_size(relid) AS total_bytes, n_live_tup, n_dead_tup, n_tup_upd + n_tup_del AS churn, last_autovacuum, autovacuum_count, age(relfrozenxid) AS xid_age FROM pg_stat_user_tables JOIN pg_class ON pg_class.oid = relid WHERE n_live_tup > 1000 ) SELECT table_name, pg_size_pretty(total_bytes) AS size, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct, churn, autovacuum_count AS vacuums, xid_age, CASE WHEN round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) > 20 THEN 'BLOATED' WHEN xid_age > 500000000 THEN 'XID_WARNING' WHEN last_autovacuum IS NULL AND n_dead_tup > 10000 THEN 'NEVER_VACUUMED' ELSE 'OK' END AS status FROM vacuum_stats ORDER BY CASE WHEN round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) > 20 THEN 1 WHEN xid_age > 500000000 THEN 2 ELSE 3 END, n_dead_tup DESC LIMIT 20;
This gives you a single view of which tables need immediate attention and why.
Conclusion
Understanding MVCC and vacuum is fundamental to running healthy Postgres in production. Dead tuples are a natural consequence of Postgres's concurrency model — they're not a bug, they're a feature that needs maintenance. The key takeaways: autovacuum handles most workloads when properly tuned, VACUUM FULL is almost never the right answer, and transaction ID wraparound is preventable but dangerous if ignored.
Related Resources
Deep dive into autovacuum, bloat, and tuning strategies.
Official PostgreSQL docs on autovacuum configuration and maintenance strategies.
Reduce vacuum workload with HOT updates and fill factor tuning.
Monitor vacuum activity and table bloat with Snowflake Postgres Insights.
This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances