Skip to content

PostgreSQL Maintenance & Vacuum Best Practices

Objective: Master senior-level PostgreSQL maintenance and vacuum patterns for production systems. When you need to maintain database health, when you want to optimize vacuum operations, when you need enterprise-grade maintenance strategies—these best practices become your weapon of choice.

Core Principles

  • Automated Maintenance: Implement automated maintenance procedures
  • Vacuum Optimization: Tune vacuum operations for performance
  • Statistics Updates: Keep table statistics current
  • Index Maintenance: Maintain index health and performance
  • Monitoring: Monitor maintenance operations and their impact

Vacuum Operations

Vacuum Configuration

-- Create vacuum configuration table
CREATE TABLE vacuum_config (
    id SERIAL PRIMARY KEY,
    table_name VARCHAR(100) NOT NULL,
    vacuum_enabled BOOLEAN DEFAULT TRUE,
    autovacuum_enabled BOOLEAN DEFAULT TRUE,
    vacuum_scale_factor NUMERIC DEFAULT 0.2,
    vacuum_threshold INTEGER DEFAULT 50,
    analyze_scale_factor NUMERIC DEFAULT 0.1,
    analyze_threshold INTEGER DEFAULT 50,
    freeze_min_age INTEGER DEFAULT 200000000,
    freeze_max_age INTEGER DEFAULT 2000000000,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Create function to configure table vacuum settings
CREATE OR REPLACE FUNCTION configure_table_vacuum(
    p_table_name VARCHAR(100),
    p_vacuum_enabled BOOLEAN DEFAULT TRUE,
    p_autovacuum_enabled BOOLEAN DEFAULT TRUE,
    p_vacuum_scale_factor NUMERIC DEFAULT 0.2,
    p_vacuum_threshold INTEGER DEFAULT 50
)
RETURNS VOID AS $$
BEGIN
    -- Insert or update vacuum configuration
    INSERT INTO vacuum_config (
        table_name, vacuum_enabled, autovacuum_enabled, 
        vacuum_scale_factor, vacuum_threshold
    ) VALUES (
        p_table_name, p_vacuum_enabled, p_autovacuum_enabled,
        p_vacuum_scale_factor, p_vacuum_threshold
    ) ON CONFLICT (table_name) 
    DO UPDATE SET 
        vacuum_enabled = EXCLUDED.vacuum_enabled,
        autovacuum_enabled = EXCLUDED.autovacuum_enabled,
        vacuum_scale_factor = EXCLUDED.vacuum_scale_factor,
        vacuum_threshold = EXCLUDED.vacuum_threshold,
        updated_at = CURRENT_TIMESTAMP;

    -- Apply configuration to table
    EXECUTE format('ALTER TABLE %I SET (autovacuum_enabled = %s)', 
                   p_table_name, p_autovacuum_enabled);
    EXECUTE format('ALTER TABLE %I SET (autovacuum_vacuum_scale_factor = %s)', 
                   p_table_name, p_vacuum_scale_factor);
    EXECUTE format('ALTER TABLE %I SET (autovacuum_vacuum_threshold = %s)', 
                   p_table_name, p_vacuum_threshold);
END;
$$ LANGUAGE plpgsql;

Vacuum Monitoring

-- Create vacuum monitoring table
CREATE TABLE vacuum_history (
    id SERIAL PRIMARY KEY,
    table_name VARCHAR(100) NOT NULL,
    vacuum_type VARCHAR(20) NOT NULL,
    started_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ,
    duration_seconds INTEGER,
    pages_removed INTEGER,
    pages_remain INTEGER,
    pages_new INTEGER,
    pages_deleted INTEGER,
    pages_vacuumed INTEGER,
    pages_frozen INTEGER,
    tuples_deleted INTEGER,
    tuples_updated INTEGER,
    tuples_moved INTEGER,
    status VARCHAR(20) DEFAULT 'running',
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Create function to start vacuum monitoring
CREATE OR REPLACE FUNCTION start_vacuum_monitoring(
    p_table_name VARCHAR(100),
    p_vacuum_type VARCHAR(20) DEFAULT 'manual'
)
RETURNS INTEGER AS $$
DECLARE
    vacuum_id INTEGER;
BEGIN
    INSERT INTO vacuum_history (
        table_name, vacuum_type, started_at
    ) VALUES (
        p_table_name, p_vacuum_type, CURRENT_TIMESTAMP
    ) RETURNING id INTO vacuum_id;

    RETURN vacuum_id;
END;
$$ LANGUAGE plpgsql;

-- Create function to complete vacuum monitoring
CREATE OR REPLACE FUNCTION complete_vacuum_monitoring(
    p_vacuum_id INTEGER,
    p_pages_removed INTEGER DEFAULT 0,
    p_pages_remain INTEGER DEFAULT 0,
    p_pages_new INTEGER DEFAULT 0,
    p_pages_deleted INTEGER DEFAULT 0,
    p_pages_vacuumed INTEGER DEFAULT 0,
    p_pages_frozen INTEGER DEFAULT 0,
    p_tuples_deleted INTEGER DEFAULT 0,
    p_tuples_updated INTEGER DEFAULT 0,
    p_tuples_moved INTEGER DEFAULT 0
)
RETURNS VOID AS $$
DECLARE
    start_time TIMESTAMPTZ;
    duration_seconds INTEGER;
BEGIN
    -- Get start time
    SELECT started_at INTO start_time
    FROM vacuum_history
    WHERE id = p_vacuum_id;

    -- Calculate duration
    duration_seconds := EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - start_time));

    -- Update vacuum record
    UPDATE vacuum_history
    SET completed_at = CURRENT_TIMESTAMP,
        duration_seconds = duration_seconds,
        pages_removed = p_pages_removed,
        pages_remain = p_pages_remain,
        pages_new = p_pages_new,
        pages_deleted = p_pages_deleted,
        pages_vacuumed = p_pages_vacuumed,
        pages_frozen = p_pages_frozen,
        tuples_deleted = p_tuples_deleted,
        tuples_updated = p_tuples_updated,
        tuples_moved = p_tuples_moved,
        status = 'completed'
    WHERE id = p_vacuum_id;
END;
$$ LANGUAGE plpgsql;

Automated Maintenance

Maintenance Scheduling

-- Create maintenance schedule table
CREATE TABLE maintenance_schedule (
    id SERIAL PRIMARY KEY,
    task_name VARCHAR(100) UNIQUE NOT NULL,
    task_type VARCHAR(50) NOT NULL,
    schedule_cron VARCHAR(100) NOT NULL,
    is_enabled BOOLEAN DEFAULT TRUE,
    last_run TIMESTAMPTZ,
    next_run TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Create function to schedule maintenance task
CREATE OR REPLACE FUNCTION schedule_maintenance_task(
    p_task_name VARCHAR(100),
    p_task_type VARCHAR(50),
    p_schedule_cron VARCHAR(100),
    p_is_enabled BOOLEAN DEFAULT TRUE
)
RETURNS VOID AS $$
BEGIN
    INSERT INTO maintenance_schedule (
        task_name, task_type, schedule_cron, is_enabled
    ) VALUES (
        p_task_name, p_task_type, p_schedule_cron, p_is_enabled
    ) ON CONFLICT (task_name) 
    DO UPDATE SET 
        task_type = EXCLUDED.task_type,
        schedule_cron = EXCLUDED.schedule_cron,
        is_enabled = EXCLUDED.is_enabled;
END;
$$ LANGUAGE plpgsql;

-- Create function to run maintenance task
CREATE OR REPLACE FUNCTION run_maintenance_task(p_task_name VARCHAR(100))
RETURNS BOOLEAN AS $$
DECLARE
    task_type VARCHAR(50);
    task_sql TEXT;
BEGIN
    -- Get task type
    SELECT task_type INTO task_type
    FROM maintenance_schedule
    WHERE task_name = p_task_name AND is_enabled = TRUE;

    IF task_type IS NULL THEN
        RETURN FALSE;
    END IF;

    -- Execute task based on type
    CASE task_type
        WHEN 'vacuum' THEN
            EXECUTE format('VACUUM ANALYZE %I', p_task_name);
        WHEN 'reindex' THEN
            EXECUTE format('REINDEX TABLE %I', p_task_name);
        WHEN 'analyze' THEN
            EXECUTE format('ANALYZE %I', p_task_name);
        WHEN 'vacuum_full' THEN
            EXECUTE format('VACUUM FULL %I', p_task_name);
        ELSE
            RAISE EXCEPTION 'Unknown task type: %', task_type;
    END CASE;

    -- Update last run time
    UPDATE maintenance_schedule
    SET last_run = CURRENT_TIMESTAMP
    WHERE task_name = p_task_name;

    RETURN TRUE;
END;
$$ LANGUAGE plpgsql;

Maintenance Automation

# maintenance/postgres_maintenance.py
import psycopg2
import json
from datetime import datetime, timedelta
import logging
import schedule
import time

class PostgreSQLMaintenance:
    def __init__(self, connection_params):
        self.conn_params = connection_params
        self.setup_logging()

    def setup_logging(self):
        """Setup logging."""
        logging.basicConfig(level=logging.INFO)
        self.logger = logging.getLogger(__name__)

    def run_vacuum_analyze(self, table_name):
        """Run VACUUM ANALYZE on table."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                # Start monitoring
                cur.execute("SELECT start_vacuum_monitoring(%s, %s)", (table_name, 'automated'))
                vacuum_id = cur.fetchone()[0]

                # Run vacuum analyze
                cur.execute(f"VACUUM ANALYZE {table_name}")

                # Complete monitoring
                cur.execute("SELECT complete_vacuum_monitoring(%s)", (vacuum_id,))

                conn.commit()
                self.logger.info(f"VACUUM ANALYZE completed for {table_name}")

        except Exception as e:
            self.logger.error(f"VACUUM ANALYZE failed for {table_name}: {e}")
            conn.rollback()
            raise
        finally:
            conn.close()

    def run_reindex(self, table_name):
        """Run REINDEX on table."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute(f"REINDEX TABLE {table_name}")
                conn.commit()
                self.logger.info(f"REINDEX completed for {table_name}")

        except Exception as e:
            self.logger.error(f"REINDEX failed for {table_name}: {e}")
            conn.rollback()
            raise
        finally:
            conn.close()

    def run_analyze(self, table_name):
        """Run ANALYZE on table."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute(f"ANALYZE {table_name}")
                conn.commit()
                self.logger.info(f"ANALYZE completed for {table_name}")

        except Exception as e:
            self.logger.error(f"ANALYZE failed for {table_name}: {e}")
            conn.rollback()
            raise
        finally:
            conn.close()

    def get_maintenance_tasks(self):
        """Get scheduled maintenance tasks."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT task_name, task_type, schedule_cron, is_enabled, last_run, next_run
                    FROM maintenance_schedule
                    WHERE is_enabled = TRUE
                    ORDER BY next_run
                """)

                tasks = cur.fetchall()
                return tasks

        except Exception as e:
            self.logger.error(f"Error getting maintenance tasks: {e}")
            return []
        finally:
            conn.close()

    def run_scheduled_tasks(self):
        """Run all scheduled maintenance tasks."""
        tasks = self.get_maintenance_tasks()
        current_time = datetime.now()

        for task_name, task_type, schedule_cron, is_enabled, last_run, next_run in tasks:
            if next_run and next_run <= current_time:
                try:
                    if task_type == 'vacuum':
                        self.run_vacuum_analyze(task_name)
                    elif task_type == 'reindex':
                        self.run_reindex(task_name)
                    elif task_type == 'analyze':
                        self.run_analyze(task_name)

                    self.logger.info(f"Scheduled task {task_name} completed")

                except Exception as e:
                    self.logger.error(f"Scheduled task {task_name} failed: {e}")

# Usage
if __name__ == "__main__":
    maintenance = PostgreSQLMaintenance({
        'host': 'localhost',
        'database': 'production',
        'user': 'maintenance_user',
        'password': 'maintenance_password'
    })

    # Run maintenance tasks
    maintenance.run_scheduled_tasks()

Statistics Management

Statistics Monitoring

-- Create function to get table statistics
CREATE OR REPLACE FUNCTION get_table_statistics(p_table_name VARCHAR(100))
RETURNS TABLE (
    column_name VARCHAR(100),
    n_distinct NUMERIC,
    correlation NUMERIC,
    most_common_vals TEXT,
    most_common_freqs TEXT,
    histogram_bounds TEXT,
    null_frac NUMERIC
) AS $$
BEGIN
    RETURN QUERY
    SELECT 
        s.attname::VARCHAR(100) as column_name,
        s.n_distinct,
        s.correlation,
        s.most_common_vals::TEXT,
        s.most_common_freqs::TEXT,
        s.histogram_bounds::TEXT,
        s.null_frac
    FROM pg_stats s
    WHERE s.tablename = p_table_name
    ORDER BY s.attname;
END;
$$ LANGUAGE plpgsql;

-- Create function to check statistics age
CREATE OR REPLACE FUNCTION check_statistics_age(p_table_name VARCHAR(100))
RETURNS TABLE (
    table_name VARCHAR(100),
    last_analyze TIMESTAMPTZ,
    last_autoanalyze TIMESTAMPTZ,
    analyze_age_hours NUMERIC,
    autoanalyze_age_hours NUMERIC
) AS $$
BEGIN
    RETURN QUERY
    SELECT 
        t.tablename::VARCHAR(100) as table_name,
        t.last_analyze,
        t.last_autoanalyze,
        EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - t.last_analyze)) / 3600 as analyze_age_hours,
        EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - t.last_autoanalyze)) / 3600 as autoanalyze_age_hours
    FROM pg_stat_user_tables t
    WHERE t.tablename = p_table_name;
END;
$$ LANGUAGE plpgsql;

Statistics Maintenance

-- Create function to update statistics for all tables
CREATE OR REPLACE FUNCTION update_all_statistics()
RETURNS INTEGER AS $$
DECLARE
    table_record RECORD;
    updated_count INTEGER := 0;
BEGIN
    FOR table_record IN 
        SELECT schemaname, tablename 
        FROM pg_tables 
        WHERE schemaname = 'public'
    LOOP
        EXECUTE format('ANALYZE %I.%I', table_record.schemaname, table_record.tablename);
        updated_count := updated_count + 1;
    END LOOP;

    RETURN updated_count;
END;
$$ LANGUAGE plpgsql;

-- Create function to update statistics for stale tables
CREATE OR REPLACE FUNCTION update_stale_statistics(p_hours_threshold INTEGER DEFAULT 24)
RETURNS INTEGER AS $$
DECLARE
    table_record RECORD;
    updated_count INTEGER := 0;
BEGIN
    FOR table_record IN 
        SELECT schemaname, tablename 
        FROM pg_stat_user_tables 
        WHERE last_autoanalyze < CURRENT_TIMESTAMP - (p_hours_threshold || ' hours')::interval
        OR last_autoanalyze IS NULL
    LOOP
        EXECUTE format('ANALYZE %I.%I', table_record.schemaname, table_record.tablename);
        updated_count := updated_count + 1;
    END LOOP;

    RETURN updated_count;
END;
$$ LANGUAGE plpgsql;

Index Maintenance

Index Health Monitoring

-- Create function to check index health
CREATE OR REPLACE FUNCTION check_index_health()
RETURNS TABLE (
    schemaname VARCHAR(100),
    tablename VARCHAR(100),
    indexname VARCHAR(100),
    index_size TEXT,
    idx_scan BIGINT,
    idx_tup_read BIGINT,
    idx_tup_fetch BIGINT,
    bloat_ratio NUMERIC,
    is_healthy BOOLEAN
) AS $$
BEGIN
    RETURN QUERY
    SELECT 
        s.schemaname::VARCHAR(100),
        s.tablename::VARCHAR(100),
        s.indexname::VARCHAR(100),
        pg_size_pretty(pg_relation_size(s.indexrelid)) as index_size,
        s.idx_scan,
        s.idx_tup_read,
        s.idx_tup_fetch,
        CASE 
            WHEN s.idx_tup_read > 0 THEN (s.idx_tup_read - s.idx_tup_fetch)::NUMERIC / s.idx_tup_read
            ELSE 0
        END as bloat_ratio,
        (s.idx_scan > 0 AND s.idx_tup_fetch > 0) as is_healthy
    FROM pg_stat_user_indexes s
    ORDER BY s.idx_scan DESC;
END;
$$ LANGUAGE plpgsql;

-- Create function to find unused indexes
CREATE OR REPLACE FUNCTION find_unused_indexes()
RETURNS TABLE (
    schemaname VARCHAR(100),
    tablename VARCHAR(100),
    indexname VARCHAR(100),
    index_size TEXT,
    index_size_bytes BIGINT
) AS $$
BEGIN
    RETURN QUERY
    SELECT 
        s.schemaname::VARCHAR(100),
        s.tablename::VARCHAR(100),
        s.indexname::VARCHAR(100),
        pg_size_pretty(pg_relation_size(s.indexrelid)) as index_size,
        pg_relation_size(s.indexrelid) as index_size_bytes
    FROM pg_stat_user_indexes s
    WHERE s.idx_scan = 0
    AND s.indexname NOT LIKE '%_pkey'
    AND s.indexname NOT LIKE '%_unique_%'
    ORDER BY pg_relation_size(s.indexrelid) DESC;
END;
$$ LANGUAGE plpgsql;

Index Maintenance Automation

-- Create function to reindex unused indexes
CREATE OR REPLACE FUNCTION reindex_unused_indexes()
RETURNS INTEGER AS $$
DECLARE
    index_record RECORD;
    reindexed_count INTEGER := 0;
BEGIN
    FOR index_record IN 
        SELECT schemaname, indexname 
        FROM find_unused_indexes()
    LOOP
        EXECUTE format('REINDEX INDEX %I.%I', index_record.schemaname, index_record.indexname);
        reindexed_count := reindexed_count + 1;
    END LOOP;

    RETURN reindexed_count;
END;
$$ LANGUAGE plpgsql;

-- Create function to drop unused indexes
CREATE OR REPLACE FUNCTION drop_unused_indexes()
RETURNS INTEGER AS $$
DECLARE
    index_record RECORD;
    dropped_count INTEGER := 0;
BEGIN
    FOR index_record IN 
        SELECT schemaname, indexname 
        FROM find_unused_indexes()
    LOOP
        EXECUTE format('DROP INDEX %I.%I', index_record.schemaname, index_record.indexname);
        dropped_count := dropped_count + 1;
    END LOOP;

    RETURN dropped_count;
END;
$$ LANGUAGE plpgsql;

Maintenance Monitoring

Maintenance Performance Monitoring

# monitoring/maintenance_monitor.py
import psycopg2
import json
from datetime import datetime, timedelta
import logging

class MaintenanceMonitor:
    def __init__(self, connection_params):
        self.conn_params = connection_params
        self.setup_logging()

    def setup_logging(self):
        """Setup logging."""
        logging.basicConfig(level=logging.INFO)
        self.logger = logging.getLogger(__name__)

    def get_vacuum_statistics(self):
        """Get vacuum statistics."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT 
                        table_name,
                        COUNT(*) as vacuum_count,
                        AVG(duration_seconds) as avg_duration,
                        MAX(duration_seconds) as max_duration,
                        SUM(pages_removed) as total_pages_removed,
                        SUM(tuples_deleted) as total_tuples_deleted
                    FROM vacuum_history
                    WHERE started_at > CURRENT_DATE - INTERVAL '7 days'
                    GROUP BY table_name
                    ORDER BY vacuum_count DESC
                """)

                vacuum_stats = cur.fetchall()
                return vacuum_stats

        except Exception as e:
            self.logger.error(f"Error getting vacuum statistics: {e}")
            return []
        finally:
            conn.close()

    def get_index_health(self):
        """Get index health metrics."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("SELECT * FROM check_index_health()")

                index_health = cur.fetchall()
                return index_health

        except Exception as e:
            self.logger.error(f"Error getting index health: {e}")
            return []
        finally:
            conn.close()

    def get_maintenance_summary(self):
        """Get maintenance summary."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT 
                        'vacuum' as task_type,
                        COUNT(*) as task_count,
                        AVG(duration_seconds) as avg_duration
                    FROM vacuum_history
                    WHERE started_at > CURRENT_DATE - INTERVAL '7 days'
                    UNION ALL
                    SELECT 
                        'analyze' as task_type,
                        COUNT(*) as task_count,
                        AVG(duration_seconds) as avg_duration
                    FROM vacuum_history
                    WHERE vacuum_type = 'analyze'
                    AND started_at > CURRENT_DATE - INTERVAL '7 days'
                """)

                summary = cur.fetchall()
                return summary

        except Exception as e:
            self.logger.error(f"Error getting maintenance summary: {e}")
            return []
        finally:
            conn.close()

    def generate_maintenance_report(self):
        """Generate maintenance report."""
        vacuum_stats = self.get_vacuum_statistics()
        index_health = self.get_index_health()
        maintenance_summary = self.get_maintenance_summary()

        report = {
            'report_timestamp': datetime.now().isoformat(),
            'vacuum_statistics': vacuum_stats,
            'index_health': index_health,
            'maintenance_summary': maintenance_summary
        }

        return report

# Usage
if __name__ == "__main__":
    monitor = MaintenanceMonitor({
        'host': 'localhost',
        'database': 'production',
        'user': 'monitor_user',
        'password': 'monitor_password'
    })

    report = monitor.generate_maintenance_report()
    print(json.dumps(report, indent=2))

TL;DR Runbook

Quick Start

-- 1. Configure table vacuum settings
SELECT configure_table_vacuum('users', TRUE, TRUE, 0.1, 100);

-- 2. Run manual vacuum
VACUUM ANALYZE users;

-- 3. Check vacuum statistics
SELECT * FROM get_table_statistics('users');

-- 4. Monitor index health
SELECT * FROM check_index_health();

-- 5. Update statistics
SELECT update_stale_statistics(24);

Essential Patterns

# Complete PostgreSQL maintenance and vacuum setup
def setup_postgresql_maintenance_vacuum():
    # 1. Vacuum operations
    # 2. Automated maintenance
    # 3. Statistics management
    # 4. Index maintenance
    # 5. Maintenance monitoring
    # 6. Performance optimization
    # 7. Health checks
    # 8. Automation scheduling

    print("PostgreSQL maintenance and vacuum setup complete!")

This guide provides the complete machinery for PostgreSQL maintenance and vacuum excellence. Each pattern includes implementation examples, maintenance strategies, and real-world usage patterns for enterprise PostgreSQL maintenance systems.