Skip to content

PostgreSQL Performance Tuning Best Practices

Objective: Master senior-level PostgreSQL performance optimization for production systems. When you need to optimize query performance, when you want to tune database configuration, when you need enterprise-grade performance strategies—these best practices become your weapon of choice.

Core Principles

  • Measure First: Profile before optimizing
  • Index Strategy: Right indexes for right queries
  • Configuration Tuning: Optimize for your workload
  • Query Optimization: Write efficient SQL
  • Monitoring: Continuous performance monitoring

Performance Analysis

Query Performance Monitoring

-- Enable query statistics
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- View slowest queries
SELECT 
    query,
    calls,
    total_time,
    mean_time,
    stddev_time,
    rows,
    100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS hit_percent
FROM pg_stat_statements 
ORDER BY total_time DESC 
LIMIT 20;

-- Reset statistics
SELECT pg_stat_statements_reset();

-- View query execution plans
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) 
SELECT * FROM users WHERE email = 'user@example.com';

-- View table statistics
SELECT 
    schemaname,
    tablename,
    n_tup_ins,
    n_tup_upd,
    n_tup_del,
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;

Performance Monitoring Scripts

# monitoring/performance_monitor.py
import psycopg2
import json
import time
from datetime import datetime, timedelta

class PostgreSQLPerformanceMonitor:
    def __init__(self, connection_params):
        self.conn_params = connection_params
        self.conn = None

    def connect(self):
        """Connect to PostgreSQL."""
        self.conn = psycopg2.connect(**self.conn_params)

    def get_slow_queries(self, limit=10):
        """Get slowest queries."""
        with self.conn.cursor() as cur:
            cur.execute("""
                SELECT 
                    query,
                    calls,
                    total_time,
                    mean_time,
                    rows,
                    100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS hit_percent
                FROM pg_stat_statements 
                ORDER BY total_time DESC 
                LIMIT %s;
            """, (limit,))
            return cur.fetchall()

    def get_table_bloat(self):
        """Get table bloat information."""
        with self.conn.cursor() as cur:
            cur.execute("""
                SELECT 
                    schemaname,
                    tablename,
                    n_live_tup,
                    n_dead_tup,
                    ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS bloat_percent
                FROM pg_stat_user_tables
                WHERE n_live_tup > 0
                ORDER BY bloat_percent DESC;
            """)
            return cur.fetchall()

    def get_index_usage(self):
        """Get index usage statistics."""
        with self.conn.cursor() as cur:
            cur.execute("""
                SELECT 
                    schemaname,
                    tablename,
                    indexname,
                    idx_tup_read,
                    idx_tup_fetch,
                    idx_scan,
                    CASE 
                        WHEN idx_scan = 0 THEN 0
                        ELSE ROUND(100.0 * idx_tup_fetch / NULLIF(idx_tup_read, 0), 2)
                    END AS efficiency_percent
                FROM pg_stat_user_indexes
                ORDER BY idx_scan DESC;
            """)
            return cur.fetchall()

    def get_connection_stats(self):
        """Get connection statistics."""
        with self.conn.cursor() as cur:
            cur.execute("""
                SELECT 
                    count(*) as total_connections,
                    count(*) FILTER (WHERE state = 'active') as active_connections,
                    count(*) FILTER (WHERE state = 'idle') as idle_connections,
                    count(*) FILTER (WHERE state = 'idle in transaction') as idle_in_transaction
                FROM pg_stat_activity;
            """)
            return cur.fetchone()

    def get_database_size(self):
        """Get database size information."""
        with self.conn.cursor() as cur:
            cur.execute("""
                SELECT 
                    pg_database.datname,
                    pg_size_pretty(pg_database_size(pg_database.datname)) as size
                FROM pg_database
                WHERE datname NOT IN ('template0', 'template1', 'postgres')
                ORDER BY pg_database_size(pg_database.datname) DESC;
            """)
            return cur.fetchall()

    def generate_performance_report(self):
        """Generate comprehensive performance report."""
        report = {
            'timestamp': datetime.now().isoformat(),
            'slow_queries': self.get_slow_queries(),
            'table_bloat': self.get_table_bloat(),
            'index_usage': self.get_index_usage(),
            'connection_stats': self.get_connection_stats(),
            'database_sizes': self.get_database_size()
        }
        return report

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

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

Configuration Optimization

Memory Configuration

-- postgresql.conf memory settings
-- Adjust based on your system's available RAM

-- Shared memory (25% of total RAM)
shared_buffers = 2GB

-- Effective cache size (75% of total RAM)
effective_cache_size = 6GB

-- Work memory for sorting and hashing
work_mem = 64MB

-- Maintenance work memory
maintenance_work_mem = 256MB

-- Hash table size for hash joins
hash_mem_multiplier = 2.0

-- Maximum memory for parallel workers
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_parallel_maintenance_workers = 4

I/O Configuration

-- I/O configuration for different storage types

-- For SSD storage
random_page_cost = 1.1
effective_io_concurrency = 200

-- For traditional HDD
-- random_page_cost = 4.0
-- effective_io_concurrency = 1

-- WAL configuration
wal_level = replica
max_wal_size = 2GB
min_wal_size = 80MB
checkpoint_completion_target = 0.9
checkpoint_timeout = 15min

-- Background writer
bgwriter_delay = 200ms
bgwriter_lru_maxpages = 100
bgwriter_lru_multiplier = 2.0

Connection Configuration

-- Connection settings
max_connections = 200
superuser_reserved_connections = 3

-- Connection pooling settings
tcp_keepalives_idle = 600
tcp_keepalives_interval = 30
tcp_keepalives_count = 3

-- Statement timeout
statement_timeout = 0
lock_timeout = 0
idle_in_transaction_session_timeout = 0

Indexing Strategies

Index Types and Usage

-- B-tree indexes (default)
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_created_at ON posts(created_at);

-- Partial indexes for filtered queries
CREATE INDEX idx_posts_published ON posts(created_at) 
WHERE published = true;

-- Composite indexes for multi-column queries
CREATE INDEX idx_posts_user_published ON posts(user_id, published, created_at);

-- Covering indexes (include columns)
CREATE INDEX idx_posts_covering ON posts(user_id, created_at) 
INCLUDE (title, published);

-- GIN indexes for array and JSONB columns
CREATE INDEX idx_posts_tags_gin ON posts USING GIN(tags);
CREATE INDEX idx_posts_metadata_gin ON posts USING GIN(metadata);

-- GiST indexes for geometric data
CREATE INDEX idx_locations_geom_gist ON locations USING GIST(geom);

-- Hash indexes for equality comparisons
CREATE INDEX idx_users_username_hash ON users USING HASH(username);

-- BRIN indexes for large tables with natural ordering
CREATE INDEX idx_logs_timestamp_brin ON logs USING BRIN(created_at);

Index Maintenance

-- Analyze index usage
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;

-- Find unused indexes
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY schemaname, tablename;

-- Get index size information
SELECT 
    schemaname,
    tablename,
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;

Query Optimization

Query Analysis Tools

-- Enable query timing
\timing on

-- Explain plans with different options
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) 
SELECT u.username, p.title, p.created_at
FROM users u
JOIN posts p ON u.id = p.user_id
WHERE u.email = 'user@example.com'
ORDER BY p.created_at DESC
LIMIT 10;

-- Explain with verbose output
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON)
SELECT * FROM posts 
WHERE tags @> '["postgresql"]'::jsonb
AND created_at > '2024-01-01';

-- Cost analysis
EXPLAIN (ANALYZE, BUFFERS, COSTS, FORMAT JSON)
SELECT COUNT(*) FROM posts 
WHERE published = true 
AND created_at BETWEEN '2024-01-01' AND '2024-12-31';

Common Optimization Patterns

-- 1. Use appropriate WHERE clauses
-- Bad: Full table scan
SELECT * FROM posts WHERE LOWER(title) LIKE '%postgresql%';

-- Good: Use indexes
SELECT * FROM posts WHERE title ILIKE '%postgresql%';

-- 2. Avoid SELECT * in production
-- Bad
SELECT * FROM users WHERE email = 'user@example.com';

-- Good
SELECT id, username, email FROM users WHERE email = 'user@example.com';

-- 3. Use LIMIT for large result sets
SELECT * FROM posts 
WHERE published = true 
ORDER BY created_at DESC 
LIMIT 100;

-- 4. Optimize JOINs
-- Use appropriate JOIN types
SELECT u.username, COUNT(p.id) as post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id, u.username;

-- 5. Use EXISTS instead of IN for large datasets
-- Bad
SELECT * FROM users 
WHERE id IN (SELECT user_id FROM posts WHERE published = true);

-- Good
SELECT * FROM users u
WHERE EXISTS (
    SELECT 1 FROM posts p 
    WHERE p.user_id = u.id AND p.published = true
);

Partitioning for Performance

Table Partitioning

-- Create partitioned table
CREATE TABLE posts_partitioned (
    id SERIAL,
    user_id INTEGER,
    title VARCHAR(200),
    content TEXT,
    published BOOLEAN,
    created_at TIMESTAMP
) PARTITION BY RANGE (created_at);

-- Create partitions
CREATE TABLE posts_2024_q1 PARTITION OF posts_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');

CREATE TABLE posts_2024_q2 PARTITION OF posts_partitioned
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

CREATE TABLE posts_2024_q3 PARTITION OF posts_partitioned
FOR VALUES FROM ('2024-07-01') TO ('2024-10-01');

CREATE TABLE posts_2024_q4 PARTITION OF posts_partitioned
FOR VALUES FROM ('2024-10-01') TO ('2025-01-01');

-- Create indexes on partitions
CREATE INDEX idx_posts_2024_q1_created_at ON posts_2024_q1(created_at);
CREATE INDEX idx_posts_2024_q1_user_id ON posts_2024_q1(user_id);

-- Automatic partition creation
CREATE OR REPLACE FUNCTION create_monthly_partition(table_name text, start_date date)
RETURNS void AS $$
DECLARE
    partition_name text;
    end_date date;
BEGIN
    partition_name := table_name || '_' || to_char(start_date, 'YYYY_MM');
    end_date := start_date + interval '1 month';

    EXECUTE format('CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
                   partition_name, table_name, start_date, end_date);

    EXECUTE format('CREATE INDEX %I ON %I(created_at)', 
                   'idx_' || partition_name || '_created_at', partition_name);
END;
$$ LANGUAGE plpgsql;

Connection Pooling

PgBouncer Configuration

# pgbouncer.ini
[databases]
production = host=localhost port=5432 dbname=production
staging = host=localhost port=5432 dbname=staging

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
max_db_connections = 100
max_user_connections = 50
server_round_robin = 1
ignore_startup_parameters = extra_float_digits
application_name_add_host = 1

# Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1

Application-Level Pooling

# connection_pool.py
import psycopg2
from psycopg2 import pool
import threading
import time

class PostgreSQLConnectionPool:
    def __init__(self, min_conn=5, max_conn=20, **kwargs):
        self.pool = psycopg2.pool.ThreadedConnectionPool(
            min_conn, max_conn, **kwargs
        )
        self.lock = threading.Lock()

    def get_connection(self):
        """Get connection from pool."""
        return self.pool.getconn()

    def return_connection(self, conn):
        """Return connection to pool."""
        self.pool.putconn(conn)

    def execute_query(self, query, params=None):
        """Execute query using connection pool."""
        conn = None
        try:
            conn = self.get_connection()
            with conn.cursor() as cur:
                cur.execute(query, params)
                if cur.description:
                    return cur.fetchall()
                return cur.rowcount
        finally:
            if conn:
                self.return_connection(conn)

    def close_all(self):
        """Close all connections in pool."""
        self.pool.closeall()

# Usage
pool = PostgreSQLConnectionPool(
    min_conn=5,
    max_conn=20,
    host='localhost',
    database='production',
    user='app_user',
    password='app_password'
)

# Execute queries
results = pool.execute_query("SELECT * FROM users WHERE active = %s", (True,))

Monitoring and Alerting

Performance Metrics Collection

# monitoring/metrics_collector.py
import psycopg2
import time
import json
from prometheus_client import Gauge, Counter, Histogram, start_http_server

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

        # Prometheus metrics
        self.connections_total = Gauge('postgresql_connections_total', 'Total connections')
        self.active_connections = Gauge('postgresql_active_connections', 'Active connections')
        self.database_size = Gauge('postgresql_database_size_bytes', 'Database size in bytes')
        self.slow_queries = Counter('postgresql_slow_queries_total', 'Total slow queries')
        self.query_duration = Histogram('postgresql_query_duration_seconds', 'Query duration')

    def collect_metrics(self):
        """Collect PostgreSQL metrics."""
        try:
            conn = psycopg2.connect(**self.conn_params)

            with conn.cursor() as cur:
                # Connection metrics
                cur.execute("SELECT count(*) FROM pg_stat_activity;")
                total_connections = cur.fetchone()[0]
                self.connections_total.set(total_connections)

                cur.execute("SELECT count(*) FROM pg_stat_activity WHERE state = 'active';")
                active_connections = cur.fetchone()[0]
                self.active_connections.set(active_connections)

                # Database size
                cur.execute("SELECT pg_database_size(current_database());")
                db_size = cur.fetchone()[0]
                self.database_size.set(db_size)

                # Slow queries
                cur.execute("""
                    SELECT count(*) FROM pg_stat_statements 
                    WHERE mean_time > 1000;
                """)
                slow_queries = cur.fetchone()[0]
                self.slow_queries.inc(slow_queries)

            conn.close()

        except Exception as e:
            print(f"Error collecting metrics: {e}")

    def start_monitoring(self, port=8000):
        """Start metrics server."""
        start_http_server(port)
        print(f"Metrics server started on port {port}")

        while True:
            self.collect_metrics()
            time.sleep(30)

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

    metrics.start_monitoring()

TL;DR Runbook

Quick Start

-- 1. Enable performance monitoring
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- 2. Analyze slow queries
SELECT query, total_time, mean_time 
FROM pg_stat_statements 
ORDER BY total_time DESC LIMIT 10;

-- 3. Check table bloat
SELECT schemaname, tablename, n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000;

Essential Patterns

# Complete PostgreSQL performance tuning setup
def setup_postgresql_performance_tuning():
    # 1. Performance monitoring
    # 2. Configuration optimization
    # 3. Indexing strategies
    # 4. Query optimization
    # 5. Partitioning
    # 6. Connection pooling
    # 7. Metrics collection
    # 8. Alerting

    print("PostgreSQL performance tuning setup complete!")

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