What is VACUUM in PostgreSQL?
For regular cleanup — tune autovacuum, use pg_repack.
🟢 Junior Level
VACUUM — a PostgreSQL process that cleans up “dead” rows left behind after UPDATE and DELETE.
Simple analogy: Imagine a bulletin board. When you tear off a note, space remains. VACUUM is when you clean up the torn pieces of paper and free up space for new notes.
Why it’s needed:
- In PostgreSQL,
UPDATEdoes not overwrite a row, but creates a new one. The old one remains “dead”. DELETEdoes not physically remove the row, but marks it as deleted.- Without VACUUM, the table would grow infinitely.
Example:
-- Automatic VACUUM (runs on its own)
-- Configured in postgresql.conf:
-- autovacuum = on
-- Manual run
VACUUM users; -- Regular
VACUUM FULL users; -- Full (returns space to OS)
VACUUM VERBOSE users; -- With details
Important:
- Regular VACUUM does NOT return space to the operating system
- It only marks space as free for new data
VACUUM FULLreturns space, but locks the table
🟡 Middle Level
How MVCC creates dead rows
-- users table: 1000 rows
UPDATE users SET status = 'ACTIVE' WHERE id = 1;
-- Old version of the row remains (dead tuple)
-- New version is created
DELETE FROM users WHERE id = 2;
-- Row marked as deleted (dead tuple)
-- Physically remains in the file
-- After 10,000 UPDATE/DELETE:
-- Live rows: 1000
-- Dead rows: 10,000 ← taking up space!
Regular VACUUM vs VACUUM FULL
| Parameter | VACUUM | VACUUM FULL |
|---|---|---|
| Returns space to OS | ❌ No | ✅ Yes |
| Locking | No (read/write) | AccessExclusiveLock |
| Speed | Fast | Slow |
| Space for new data | ✅ Frees | ✅ File shrinks |
| Production | ✅ Safe | ❌ Maintenance only |
When VACUUM FULL is acceptable:
- During a maintenance window with no load
- On a replica temporarily removed from the load balancer
- For a one-time “revival” after a mass DELETE
For regular cleanup — tune autovacuum, use pg_repack.
VACUUM phases
1. Scanning Heap — searching for dead rows
→ Skips "clean" pages via Visibility Map
2. Vacuuming Indexes — removing references from indexes
→ Can be parallel (PG 13+)
3. Vacuuming Heap — cleaning table pages
4. Truncating — removing empty pages at the end of the file
→ The only phase that returns space to the OS
Autovacuum
Automatic VACUUM that runs on its own:
-- Default settings
SHOW autovacuum; -- on
SHOW autovacuum_vacuum_threshold; -- 50 rows
SHOW autovacuum_vacuum_scale_factor; -- 0.2 (20%)
-- Triggers when:
-- dead_tuples > threshold + scale_factor * total_rows
-- For a table with 1000 rows:
-- 50 + 0.2 * 1000 = 250 dead rows → trigger
-- For a table with 1M rows:
-- 50 + 0.2 * 1,000,000 = 200,050 dead rows → trigger
-- Note: for large tables, scale_factor 20% means autovacuum runs
-- VERY rarely (need to wait for 200K dead tuples)!
Tuning for specific tables
-- For a large table: more frequent and aggressive
ALTER TABLE orders SET (
autovacuum_vacuum_threshold = 1000,
autovacuum_vacuum_scale_factor = 0.05, -- 5% instead of 20%
autovacuum_vacuum_cost_delay = 1 -- Less pause → faster
);
-- For a small table: less frequent
ALTER TABLE settings SET (
autovacuum_enabled = false -- Disable (if rarely changed)
);
Common problems
- VACUUM can’t keep up
-- Check: how many dead rows SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables WHERE n_dead_tup > 100000; -- Solution: tune more aggressively ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01); - Long transactions interfere
-- VACUUM cannot remove rows that an open transaction can see -- Find the culprit SELECT pid, now() - xact_start as duration, query FROM pg_stat_activity WHERE state = 'active' ORDER BY xact_start; - VACUUM FULL locked the table
-- ❌ In production! VACUUM FULL orders; -- All INSERT/UPDATE/DELETE wait! -- ✅ Alternative without locking -- pg_repack pg_repack -t orders -d mydb
🔴 Senior Level
Visibility Map (VM) optimization
VM — a bitmap of table pages
Each page = 2 bits:
- all-visible: ALL rows visible to all transactions
- all-frozen: ALL rows frozen (for wraparound)
VACUUM uses VM:
- Skips pages with all-visible = 1
→ 10-100x faster for "clean" tables
→ Depends on the fraction of "clean" pages: if 95% all-visible → reads 5%.
If the table is constantly updated — there's almost no gain.
Micro-vacuum (HOT)
This is NOT a separate process. PostgreSQL removes old versions from HOT chains during normal operations, if all versions on a page have become invisible. “Instant” cleanup without autovacuum.
-- Thanks to HOT (Heap Only Tuple) updates
-- PostgreSQL can do "mini-cleanup" during SELECT/INSERT
-- If a HOT chain of versions exists on one page:
-- - Old versions are removed immediately
-- - Without waiting for the main VACUUM
-- This reduces the load on autovacuum
-- Especially for tables with frequent UPDATEs
Cost-based VACUUM
To prevent VACUUM from overwhelming the disk, it counts the “cost” of operations:
Operation Cost
----------------------------------
Read from cache 1.0
Read from disk 10.0
Write 20.0
autovacuum_vacuum_cost_limit = 200 -- Limit before pausing
autovacuum_vacuum_cost_delay = 2ms -- Pause after the limit
For large tables (TB):
-- Increase the limit → VACUUM works faster
ALTER TABLE huge_table SET (
autovacuum_vacuum_cost_limit = 1000, -- Instead of 200
autovacuum_vacuum_cost_delay = 1 -- Less pause
);
Parallel VACUUM (PG 13+)
-- Parallel index cleanup
VACUUM (PARALLEL 4) orders;
-- autovacuum can also use parallelism
-- For tables with many indexes → 3-5x speedup
On PG 12 and below, VACUUM runs in a single thread. To speed up: tune cost-based VACUUM (increase cost_limit, decrease delay).
Anti-wraparound VACUUM
-- The most important task of VACUUM — preventing XID Wraparound
-- age(datfrozenxid) > autovacuum_freeze_max_age (200M)
-- → Aggressive VACUUM:
-- - Cannot be cancelled (CANCEL doesn't work)
-- - Will run until everything is "frozen"
-- - Blocks new transaction creation at > 1.5B
-- Monitoring:
SELECT
datname,
age(datfrozenxid),
ROUND(100.0 * age(datfrozenxid) / 2000000000, 2) as wraparound_pct
FROM pg_database;
-- > 1.5B = 75% → CRITICAL!
pg_stat_progress_vacuum (PG 12+)
-- Real-time monitoring
SELECT
pid,
relid::regclass as table_name,
phase,
heap_blks_total,
heap_blks_scanned,
heap_blks_vacuumed,
ROUND(100.0 * heap_blks_scanned / NULLIF(heap_blks_total, 0), 2) as progress_pct
FROM pg_stat_progress_vacuum;
-- Phase:
-- 'scanning heap'
-- 'vacuuming indexes'
-- 'vacuuming heap'
-- 'truncating heap'
On PG 11 and below, pg_stat_progress_vacuum is not available. Monitor via
n_dead_tupin pg_stat_user_tables.
Index-Only VACUUM (PG 14+)
-- Skip index cleanup if they don't need it
VACUUM (INDEX_CLEANUP false) orders;
-- Emergency cleanup of Heap only
-- → Faster, but indexes are not updated
-- → Use ONLY if indexes are fine
On PG 13 and below, this parameter is not available — VACUUM always cleans both heap and indexes.
Fighting Bloat: VACUUM FULL vs pg_repack
VACUUM FULL:
-- ❌ Locks the table (AccessExclusiveLock)
-- ❌ Recreates the table and ALL indexes
-- ✅ Returns space to OS
-- ✅ Built-in, no extensions required
VACUUM FULL orders;
pg_repack:
-- ✅ No locking (brief lock at the end)
-- ✅ Returns space to OS
-- ✅ Requires extension installation
-- Installation:
CREATE EXTENSION pg_repack;
-- Run from CLI:
pg_repack -t orders -d mydb
-- How it works:
-- 1. Creates a temporary table
-- 2. Copies data
-- 3. Catches changes via triggers
-- 4. Instant swap + DROP of the old table
Edge Cases
- hot_standby_feedback and Bloat
-- On replica: hot_standby_feedback = on -- Replica asks master not to delete rows -- → Master accumulates dead tuples -- → Bloat on master! -- Solution: -- - Monitor Bloat on master -- - Regular VACUUM on replica - Partitioned Tables
-- VACUUM for a partitioned table -- → Runs for EACH partition separately -- Tuning at the partition level: ALTER TABLE orders_y2024 SET ( autovacuum_vacuum_scale_factor = 0.01 ); - Temporary Tables
-- autovacuum does NOT see temporary tables! -- Manual VACUUM needed VACUUM ANALYZE temp_import_data;
Production Experience
Real scenario #1: Autovacuum can’t keep up
- E-commerce: 50M INSERT/UPDATE per day in orders
- Autovacuum: 20% scale factor → runs rarely
- Bloat: table 100 GB (should be 10 GB)
- Solution:
ALTER TABLE orders SET ( autovacuum_vacuum_threshold = 10000, autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_limit = 1000 ); - Result: Bloat < 20%, stable performance
Real scenario #2: Wraparound emergency
- Database: autovacuum disabled for 2 years “for performance”
- age(datfrozenxid) = 1.8B (90%)
- Logs: “WARNING: database must be vacuumed within 200M transactions”
- Solution:
- Urgent
VACUUM FREEZEon the entire database - 18 hours of work
- Re-enable autovacuum
- Urgent
- Lesson: do NOT disable autovacuum GLOBALLY. For individual tables during bulk load — temporarily OK, but re-enable immediately.
Monitoring
-- 1. Dead tuples
SELECT
relname,
n_dead_tup,
last_autovacuum,
last_vacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;
-- 2. XID age
SELECT datname, age(datfrozenxid)
FROM pg_database
ORDER BY age DESC;
-- 3. Progress
SELECT * FROM pg_stat_progress_vacuum;
-- 4. Table-level autovacuum settings
SELECT
c.relname,
c.reloptions
FROM pg_class c
WHERE c.reloptions::text LIKE '%autovacuum%';
-- 5. Bloat estimate
SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) as dead_pct
FROM pg_stat_user_tables
WHERE n_live_tup > 1000
ORDER BY dead_pct DESC;
Best Practices
- Do NOT disable autovacuum GLOBALLY — tune it. For individual rarely-updated tables (reference data), you can disable locally, but monitor XID age.
- Monitor n_dead_tup — > 10% = problem
- age(datfrozenxid) < 1.5B — prevent wraparound
- pg_repack instead of VACUUM FULL in production
- Increase cost_limit for large tables
- Decrease scale_factor for frequently updated tables
- Parallel VACUUM (PG 13+) for tables with many indexes
- hot_standby_feedback = on → monitor Bloat on master
Summary for Senior
- VACUUM removes dead tuples, does NOT return space to OS
- VACUUM FULL returns space, but locks the table
- Visibility Map optimizes: skips “clean” pages
- HOT does micro-vacuum during normal operations
- Wraparound — a real threat → monitor age(datfrozenxid)
- pg_repack for production without locking
- autovacuum — tune individually for hot tables
- Long transactions — VACUUM’s biggest enemy
🎯 Interview Cheat Sheet
Must know:
- VACUUM: removes dead tuples (marks space as free), does NOT shrink files
- VACUUM FULL: recreates the table → returns space to OS, but AccessExclusiveLock
- Autovacuum: triggers when dead_tuples > threshold + scale_factor × total_rows
- Visibility Map: skips “clean” pages → VACUUM 10-100x faster
- HOT (Heap Only Tuple): micro-vacuum — removing old versions on the same page
- Cost-based VACUUM: operation costs → pause to avoid overwhelming the disk
- Anti-wraparound VACUUM: age > 200M → aggressive, CANCEL doesn’t work
- pg_stat_progress_vacuum (PG 12+): real-time monitoring
- pg_repack: VACUUM FULL without locking (triggers + swap)
- Parallel VACUUM (PG 13+): parallel index cleanup
- Index-Only VACUUM (PG 14+):
VACUUM (INDEX_CLEANUP false)— Heap only
Frequent follow-up questions:
- “Why can’t autovacuum keep up?” → scale_factor 20% is too much for hot tables
- “How to fight Bloat without locking?” → pg_repack
- “What happens if age(datfrozenxid) = 1.9B?” → Critical! Urgent VACUUM FREEZE
- “Why do long transactions interfere with VACUUM?” → VACUUM can’t remove rows the transaction can see
Red flags (do NOT say):
- ❌ “VACUUM returns space to the OS” (no, only marks as free)
- ❌ “autovacuum can be disabled” (Wraparound → READ ONLY!)
- ❌ “VACUUM FULL is safe in production” (locks ALL operations!)
- ❌ “hot_standby_feedback has no consequences” (Bloat on master!)
Related topics:
- [[How does MVCC work in PostgreSQL]] → dead tuples, Hint Bits, XID Wraparound
- [[Why is ANALYZE needed]] → Autovacuum triggers ANALYZE
- [[How to optimize slow queries]] → Bloat affects performance