Database maintenance with VACUUM and ANALYZE
Maria Garcia
Feb 2026
2 tabs
-- Basic VACUUM (reclaims dead tuple space)
VACUUM users;
-- VACUUM all tables in database
VACUUM;
-- VACUUM VERBOSE (shows progress)
VACUUM VERBOSE users;
-- VACUUM ANALYZE (vacuum + update statistics)
VACUUM ANALYZE users;
-- VACUUM FULL (rewrites table, requires exclusive lock)
VACUUM FULL users;
-- WARNING: Locks table, can take hours on large tables
-- VACUUM specific columns
VACUUM ANALYZE users (email, username);
-- ANALYZE only (update statistics without vacuum)
ANALYZE users;
-- ANALYZE specific columns
ANALYZE users (email);
-- Check dead tuples
SELECT
schemaname,
tablename,
n_live_tup,
n_dead_tup,
ROUND(100 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
-- Estimate table bloat
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
ROUND(100 * pg_relation_size(schemaname||'.'||tablename) /
NULLIF(pg_total_relation_size(schemaname||'.'||tablename), 0), 2) AS table_pct
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
-- Autovacuum configuration (postgresql.conf)
-- autovacuum = on
-- autovacuum_max_workers = 3
-- autovacuum_naptime = 1min
-- autovacuum_vacuum_threshold = 50
-- autovacuum_vacuum_scale_factor = 0.2
-- autovacuum_analyze_threshold = 50
-- autovacuum_analyze_scale_factor = 0.1
-- Per-table autovacuum settings
ALTER TABLE large_table SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000
);
-- Disable autovacuum for specific table (not recommended)
ALTER TABLE staging_table SET (
autovacuum_enabled = false
);
-- Transaction ID wraparound prevention
SELECT
datname,
age(datfrozenxid) AS xid_age,
2147483647 - age(datfrozenxid) AS xids_until_wraparound,
ROUND(100.0 * age(datfrozenxid) / 2147483647, 2) AS pct_toward_wraparound
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- VACUUM FREEZE (freeze old tuples)
VACUUM FREEZE users;
-- Check when table needs freezing
SELECT
schemaname,
tablename,
age(relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
JOIN pg_class ON pg_tables.tablename = pg_class.relname
WHERE schemaname = 'public'
ORDER BY age(relfrozenxid) DESC;
-- Monitor autovacuum activity
SELECT
pid,
now() - query_start AS duration,
query
FROM pg_stat_activity
WHERE query LIKE '%autovacuum%'
AND query NOT LIKE '%pg_stat_activity%';
-- REINDEX (rebuild indexes)
REINDEX TABLE users;
REINDEX INDEX idx_users_email;
REINDEX DATABASE mydb; -- Requires exclusive lock
-- REINDEX CONCURRENTLY (PostgreSQL 12+, doesn't lock)
REINDEX INDEX CONCURRENTLY idx_users_email;
-- Check index bloat
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
-- Scheduled maintenance script
DO $$
DECLARE
table_record RECORD;
BEGIN
-- Vacuum tables with high dead tuple ratio
FOR table_record IN
SELECT schemaname, tablename
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
AND ROUND(100 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) > 10
LOOP
RAISE NOTICE 'Vacuuming %.%', table_record.schemaname, table_record.tablename;
EXECUTE format('VACUUM ANALYZE %I.%I',
table_record.schemaname,
table_record.tablename);
END LOOP;
END $$;
-- Maintenance window for VACUUM FULL
BEGIN;
-- Lock table
LOCK TABLE large_table IN ACCESS EXCLUSIVE MODE;
-- Vacuum full
VACUUM FULL large_table;
-- Reindex
REINDEX TABLE large_table;
-- Analyze
ANALYZE large_table;
COMMIT;
-- Alternative: pg_repack (online table rewrite)
-- pg_repack -t large_table -k
-- Doesn't require exclusive lock
-- Cluster table by index (physically reorders rows)
CLUSTER users USING idx_users_created_at;
-- Future inserts won't maintain order
-- Check if table needs clustering
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
-- Update statistics sampling
ALTER TABLE large_table
ALTER COLUMN search_vector
SET STATISTICS 1000; -- Default is 100
-- Increase statistics for better query plans
ANALYZE large_table (search_vector);
-- Check statistics quality
SELECT
schemaname,
tablename,
attname,
n_distinct,
most_common_vals,
most_common_freqs
FROM pg_stats
WHERE tablename = 'users'
AND schemaname = 'public';
-- Maintenance monitoring query
WITH maintenance_status AS (
SELECT
schemaname,
tablename,
n_live_tup,
n_dead_tup,
ROUND(100 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_vacuum,
last_autovacuum,
NOW() - last_autovacuum AS time_since_autovacuum,
last_analyze,
last_autoanalyze,
NOW() - last_autoanalyze AS time_since_autoanalyze
FROM pg_stat_user_tables
)
SELECT *
FROM maintenance_status
WHERE dead_pct > 10
OR time_since_autovacuum > INTERVAL '1 day'
OR time_since_autoanalyze > INTERVAL '1 day'
ORDER BY dead_pct DESC;
-- Automated maintenance with pg_cron
CREATE EXTENSION pg_cron;
-- Schedule daily VACUUM ANALYZE at 2 AM
SELECT cron.schedule(
'nightly-vacuum',
'0 2 * * *',
$$VACUUM ANALYZE$$
);
-- Schedule weekly REINDEX at 3 AM Sunday
SELECT cron.schedule(
'weekly-reindex',
'0 3 * * 0',
$$REINDEX DATABASE mydb$$
);
-- MySQL maintenance equivalent
/*
-- Optimize table (defragment)
OPTIMIZE TABLE users;
-- Analyze table (update statistics)
ANALYZE TABLE users;
-- Check table integrity
CHECK TABLE users;
-- Repair table
REPAIR TABLE users;
*/
-- Space reclamation summary
SELECT
pg_size_pretty(SUM(pg_total_relation_size(schemaname||'.'||tablename))) AS total_size,
pg_size_pretty(SUM(pg_relation_size(schemaname||'.'||tablename))) AS table_size,
pg_size_pretty(SUM(pg_total_relation_size(schemaname||'.'||tablename) -
pg_relation_size(schemaname||'.'||tablename))) AS index_size,
COUNT(*) AS table_count
FROM pg_tables
WHERE schemaname = 'public';
2 files · sql
Explain with highlit
VACUUM reclaims storage from dead tuples. Updates and deletes leave dead rows—VACUUM removes them. Autovacuum runs automatically but needs tuning. VACUUM FULL rewrites entire table—requires lock, reclaims most space. Understanding bloat prevents performance degradation. ANALYZE updates statistics for query planner. Statistics accuracy determines query plan quality. Regular maintenance prevents emergency interventions. Monitoring vacuum activity catches problems early. Understanding when to VACUUM FULL vs regular VACUUM saves downtime. Freeze operations prevent transaction ID wraparound. Proper maintenance is essential for long-term database health and performance.