Skip to content

PostgreSQL Indexing Strategies Best Practices

Objective: Master senior-level PostgreSQL indexing patterns for production systems. When you need to optimize query performance, when you want to design efficient indexes, when you need enterprise-grade indexing strategies—these best practices become your weapon of choice.

Core Principles

  • Query-Driven: Design indexes based on actual query patterns
  • Selectivity: Prioritize high-selectivity columns
  • Composite Indexes: Order columns by selectivity and usage
  • Maintenance: Monitor and maintain index health
  • Storage: Balance performance with storage overhead

Index Types and Usage

B-tree Indexes

-- Create B-tree indexes for equality and range queries
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    first_name VARCHAR(50),
    last_name VARCHAR(50),
    age INTEGER,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    is_active BOOLEAN DEFAULT TRUE
);

-- Single column B-tree indexes
CREATE INDEX idx_users_username ON users (username);
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_age ON users (age);
CREATE INDEX idx_users_created_at ON users (created_at);
CREATE INDEX idx_users_is_active ON users (is_active);

-- Composite B-tree indexes (order by selectivity)
CREATE INDEX idx_users_name_composite ON users (last_name, first_name);
CREATE INDEX idx_users_active_created ON users (is_active, created_at);
CREATE INDEX idx_users_age_active ON users (age, is_active);

-- Partial B-tree indexes
CREATE INDEX idx_users_active_users ON users (username) WHERE is_active = TRUE;
CREATE INDEX idx_users_recent_users ON users (created_at) WHERE created_at > '2024-01-01';

GIN Indexes

-- Create GIN indexes for array and JSONB columns
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    content TEXT NOT NULL,
    tags TEXT[],
    metadata JSONB,
    search_vector TSVECTOR,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- GIN indexes for arrays
CREATE INDEX idx_articles_tags_gin ON articles USING GIN (tags);

-- GIN indexes for JSONB
CREATE INDEX idx_articles_metadata_gin ON articles USING GIN (metadata);

-- GIN indexes for full-text search
CREATE INDEX idx_articles_search_vector_gin ON articles USING GIN (search_vector);

-- GIN indexes for specific JSONB paths
CREATE INDEX idx_articles_metadata_author_gin ON articles USING GIN ((metadata -> 'author'));
CREATE INDEX idx_articles_metadata_category_gin ON articles USING GIN ((metadata -> 'category'));

GiST Indexes

-- Create GiST indexes for geometric and range data
CREATE TABLE locations (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    geom GEOMETRY(POINT, 4326),
    bbox BOX,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

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

-- GiST index for range data
CREATE INDEX idx_locations_bbox_gist ON locations USING GIST (bbox);

-- GiST index for text search (alternative to GIN)
CREATE INDEX idx_locations_name_gist ON locations USING GIST (name gist_trgm_ops);

Hash Indexes

-- Create hash indexes for equality-only queries
CREATE TABLE sessions (
    id SERIAL PRIMARY KEY,
    session_token VARCHAR(255) UNIQUE NOT NULL,
    user_id INTEGER NOT NULL,
    expires_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Hash index for session token lookups
CREATE INDEX idx_sessions_token_hash ON sessions USING HASH (session_token);

-- Note: Hash indexes only support equality operations
-- Use B-tree for range queries or sorting

BRIN Indexes

-- Create BRIN indexes for large tables with natural ordering
CREATE TABLE sensor_readings (
    id BIGSERIAL PRIMARY KEY,
    sensor_id INTEGER NOT NULL,
    reading_value DOUBLE PRECISION NOT NULL,
    reading_time TIMESTAMPTZ NOT NULL,
    location_id INTEGER
);

-- BRIN index for time-series data
CREATE INDEX idx_sensor_readings_time_brin ON sensor_readings USING BRIN (reading_time);

-- BRIN index for sensor ID (if data is ordered by sensor)
CREATE INDEX idx_sensor_readings_sensor_brin ON sensor_readings USING BRIN (sensor_id);

-- BRIN index for location (if data is ordered by location)
CREATE INDEX idx_sensor_readings_location_brin ON sensor_readings USING BRIN (location_id);

Advanced Indexing Patterns

Covering Indexes

-- Create covering indexes to avoid table lookups
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    order_date DATE NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    shipping_address TEXT,
    billing_address TEXT
);

-- Covering index for order lookups
CREATE INDEX idx_orders_customer_date_covering ON orders (customer_id, order_date) 
INCLUDE (total_amount, status);

-- Covering index for status queries
CREATE INDEX idx_orders_status_date_covering ON orders (status, order_date) 
INCLUDE (customer_id, total_amount);

Expression Indexes

-- Create indexes on expressions
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(200) NOT NULL,
    description TEXT,
    price DECIMAL(10,2) NOT NULL,
    category VARCHAR(50),
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Expression index for case-insensitive searches
CREATE INDEX idx_products_name_lower ON products (LOWER(name));

-- Expression index for date functions
CREATE INDEX idx_products_created_year ON products (EXTRACT(YEAR FROM created_at));

-- Expression index for concatenated fields
CREATE INDEX idx_products_name_category ON products (name || ' ' || category);

-- Expression index for JSONB operations
CREATE INDEX idx_products_metadata_price ON products ((metadata ->> 'price')::numeric);

Partial Indexes

-- Create partial indexes for filtered data
CREATE TABLE events (
    id SERIAL PRIMARY KEY,
    event_type VARCHAR(50) NOT NULL,
    user_id INTEGER NOT NULL,
    event_data JSONB,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    processed BOOLEAN DEFAULT FALSE
);

-- Partial index for unprocessed events
CREATE INDEX idx_events_unprocessed ON events (created_at) WHERE processed = FALSE;

-- Partial index for specific event types
CREATE INDEX idx_events_login_events ON events (user_id, created_at) WHERE event_type = 'login';

-- Partial index for recent events
CREATE INDEX idx_events_recent ON events (event_type, created_at) 
WHERE created_at > CURRENT_DATE - INTERVAL '30 days';

Index Optimization Strategies

Index Usage Analysis

-- Analyze index usage
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch,
    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;

-- Find unused indexes
SELECT 
    schemaname,
    tablename,
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

-- Find duplicate indexes
SELECT 
    t1.schemaname,
    t1.tablename,
    t1.indexname as index1,
    t2.indexname as index2,
    pg_get_indexdef(t1.indexrelid) as index1_def,
    pg_get_indexdef(t2.indexrelid) as index2_def
FROM pg_stat_user_indexes t1
JOIN pg_stat_user_indexes t2 ON t1.schemaname = t2.schemaname 
    AND t1.tablename = t2.tablename
    AND t1.indexname < t2.indexname
WHERE pg_get_indexdef(t1.indexrelid) = pg_get_indexdef(t2.indexrelid);

Index Maintenance

-- Analyze table statistics for better index usage
ANALYZE users;
ANALYZE articles;
ANALYZE orders;

-- Reindex specific indexes
REINDEX INDEX idx_users_username;
REINDEX INDEX idx_articles_search_vector_gin;

-- Reindex all indexes on a table
REINDEX TABLE users;

-- Reindex all indexes in a database
REINDEX DATABASE production;

-- Update table statistics
UPDATE pg_stat_user_tables SET last_autoanalyze = NULL WHERE relname = 'users';

Index Monitoring

# monitoring/index_monitor.py
import psycopg2
import json
from datetime import datetime
import logging

class IndexMonitor:
    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_index_usage_stats(self):
        """Get index usage statistics."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT 
                        schemaname,
                        tablename,
                        indexname,
                        idx_scan,
                        idx_tup_read,
                        idx_tup_fetch,
                        pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
                        pg_relation_size(indexrelid) as index_size_bytes
                    FROM pg_stat_user_indexes
                    ORDER BY idx_scan DESC
                """)

                stats = cur.fetchall()
                return stats

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

    def find_unused_indexes(self):
        """Find unused indexes."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT 
                        schemaname,
                        tablename,
                        indexname,
                        pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
                        pg_relation_size(indexrelid) as index_size_bytes
                    FROM pg_stat_user_indexes
                    WHERE idx_scan = 0
                    ORDER BY pg_relation_size(indexrelid) DESC
                """)

                unused_indexes = cur.fetchall()
                return unused_indexes

        except Exception as e:
            self.logger.error(f"Error finding unused indexes: {e}")
            return []
        finally:
            conn.close()

    def find_duplicate_indexes(self):
        """Find duplicate indexes."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT 
                        t1.schemaname,
                        t1.tablename,
                        t1.indexname as index1,
                        t2.indexname as index2,
                        pg_get_indexdef(t1.indexrelid) as index_def
                    FROM pg_stat_user_indexes t1
                    JOIN pg_stat_user_indexes t2 ON t1.schemaname = t2.schemaname 
                        AND t1.tablename = t2.tablename
                        AND t1.indexname < t2.indexname
                    WHERE pg_get_indexdef(t1.indexrelid) = pg_get_indexdef(t2.indexrelid)
                """)

                duplicate_indexes = cur.fetchall()
                return duplicate_indexes

        except Exception as e:
            self.logger.error(f"Error finding duplicate indexes: {e}")
            return []
        finally:
            conn.close()

    def get_index_bloat_info(self):
        """Get index bloat information."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT 
                        schemaname,
                        tablename,
                        indexname,
                        pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
                        pg_relation_size(indexrelid) as index_size_bytes,
                        n_tup_ins as inserts,
                        n_tup_upd as updates,
                        n_tup_del as deletes
                    FROM pg_stat_user_indexes
                    ORDER BY pg_relation_size(indexrelid) DESC
                """)

                bloat_info = cur.fetchall()
                return bloat_info

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

    def generate_index_report(self):
        """Generate comprehensive index report."""
        usage_stats = self.get_index_usage_stats()
        unused_indexes = self.find_unused_indexes()
        duplicate_indexes = self.find_duplicate_indexes()
        bloat_info = self.get_index_bloat_info()

        report = {
            'report_timestamp': datetime.now().isoformat(),
            'total_indexes': len(usage_stats),
            'unused_indexes_count': len(unused_indexes),
            'duplicate_indexes_count': len(duplicate_indexes),
            'usage_stats': usage_stats,
            'unused_indexes': unused_indexes,
            'duplicate_indexes': duplicate_indexes,
            'bloat_info': bloat_info
        }

        return report

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

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

Index Design Patterns

Composite Index Design

-- Design composite indexes based on query patterns
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    order_date DATE NOT NULL,
    status VARCHAR(20) NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    region VARCHAR(50),
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- Composite index for customer order queries
CREATE INDEX idx_orders_customer_date_status ON orders (customer_id, order_date, status);

-- Composite index for regional sales analysis
CREATE INDEX idx_orders_region_date_amount ON orders (region, order_date, total_amount);

-- Composite index for status-based queries
CREATE INDEX idx_orders_status_date_customer ON orders (status, order_date, customer_id);

-- Covering index for order summaries
CREATE INDEX idx_orders_customer_summary ON orders (customer_id, order_date) 
INCLUDE (status, total_amount, region);

Index for JSONB Queries

-- Create indexes for JSONB query patterns
CREATE TABLE user_profiles (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    profile_data JSONB NOT NULL,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- GIN index for full JSONB content
CREATE INDEX idx_user_profiles_data_gin ON user_profiles USING GIN (profile_data);

-- B-tree index for specific JSONB values
CREATE INDEX idx_user_profiles_email_btree ON user_profiles 
((profile_data ->> 'email'));

-- GIN index for specific JSONB paths
CREATE INDEX idx_user_profiles_preferences_gin ON user_profiles 
USING GIN ((profile_data -> 'preferences'));

-- Expression index for JSONB operations
CREATE INDEX idx_user_profiles_age_btree ON user_profiles 
(((profile_data ->> 'age')::integer));

Index Maintenance Automation

Automated Index Maintenance

# maintenance/index_maintenance.py
import psycopg2
import logging
from datetime import datetime, timedelta

class IndexMaintenance:
    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 analyze_tables(self, table_names=None):
        """Analyze table statistics."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                if table_names:
                    for table_name in table_names:
                        cur.execute(f"ANALYZE {table_name}")
                        self.logger.info(f"Analyzed table: {table_name}")
                else:
                    cur.execute("ANALYZE")
                    self.logger.info("Analyzed all tables")

                conn.commit()

        except Exception as e:
            self.logger.error(f"Error analyzing tables: {e}")
            conn.rollback()
        finally:
            conn.close()

    def reindex_table(self, table_name):
        """Reindex all indexes on a table."""
        conn = psycopg2.connect(**self.conn_params)

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

        except Exception as e:
            self.logger.error(f"Error reindexing table {table_name}: {e}")
        finally:
            conn.close()

    def reindex_index(self, index_name):
        """Reindex a specific index."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute(f"REINDEX INDEX {index_name}")
                self.logger.info(f"Reindexed index: {index_name}")

        except Exception as e:
            self.logger.error(f"Error reindexing index {index_name}: {e}")
        finally:
            conn.close()

    def drop_unused_indexes(self, dry_run=True):
        """Drop unused indexes."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT 
                        schemaname,
                        tablename,
                        indexname
                    FROM pg_stat_user_indexes
                    WHERE idx_scan = 0
                    AND indexname NOT LIKE '%_pkey'
                    AND indexname NOT LIKE '%_unique_%'
                """)

                unused_indexes = cur.fetchall()

                for schema, table, index in unused_indexes:
                    if dry_run:
                        self.logger.info(f"Would drop unused index: {schema}.{index}")
                    else:
                        cur.execute(f"DROP INDEX {schema}.{index}")
                        self.logger.info(f"Dropped unused index: {schema}.{index}")

                if not dry_run:
                    conn.commit()

        except Exception as e:
            self.logger.error(f"Error dropping unused indexes: {e}")
            conn.rollback()
        finally:
            conn.close()

    def optimize_indexes(self):
        """Run comprehensive index optimization."""
        self.logger.info("Starting index optimization")

        # Analyze all tables
        self.analyze_tables()

        # Reindex critical tables
        critical_tables = ['users', 'orders', 'products', 'articles']
        for table in critical_tables:
            try:
                self.reindex_table(table)
            except Exception as e:
                self.logger.error(f"Error reindexing {table}: {e}")

        # Drop unused indexes (dry run first)
        self.drop_unused_indexes(dry_run=True)

        self.logger.info("Index optimization completed")

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

    maintenance.optimize_indexes()

TL;DR Runbook

Quick Start

-- 1. Create B-tree indexes for equality and range queries
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_created_at ON users (created_at);

-- 2. Create GIN indexes for arrays and JSONB
CREATE INDEX idx_articles_tags_gin ON articles USING GIN (tags);
CREATE INDEX idx_articles_metadata_gin ON articles USING GIN (metadata);

-- 3. Create composite indexes for complex queries
CREATE INDEX idx_orders_customer_date ON orders (customer_id, order_date);

-- 4. Create partial indexes for filtered data
CREATE INDEX idx_events_unprocessed ON events (created_at) WHERE processed = FALSE;

Essential Patterns

# Complete PostgreSQL indexing setup
def setup_postgresql_indexing():
    # 1. Index types and usage
    # 2. Advanced indexing patterns
    # 3. Index optimization strategies
    # 4. Index design patterns
    # 5. Index maintenance automation
    # 6. Performance monitoring
    # 7. Index health checks
    # 8. Automated maintenance

    print("PostgreSQL indexing setup complete!")

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