Database Operations

PostgreSQL Index Optimization: Resolving Slow Queries

By DexNox Dev Team Published May 30, 2026

Relational database systems achieve high read performance by utilizing specialized index structures. Without indexes, PostgreSQL must scan every data page allocated to a table (a Sequential Scan) to locate matches. As tables grow to millions of rows, the time and resource consumption of sequential scans scale linearly, exhausting disk I/O and processor capacity.

PostgreSQL supports several distinct index types, each designed for specific query access patterns and data distributions. Selecting the correct index type, optimizing multi-column index keys, and avoiding write-side overhead are fundamental to maintaining production database health.


Index Architectures in PostgreSQL

1. B-Tree Indexes

B-Tree indexes are the default and most widely used index type in PostgreSQL. They implement a self-balancing tree structure (specifically a high-fanout B+ Tree variant) that stores sorted key-value pairs.

  • Use Cases: Equality searches (=), range queries (<, <=, >, >=), and sorting operations (ORDER BY).
  • Composite Indexes: When indexing multiple columns, the order of columns in the index declaration is critical. An index on (col_a, col_b) can optimize queries filtering on col_a alone, or col_a and col_b together, but cannot optimize queries filtering on col_b alone. This is known as the prefix rule.

2. BRIN (Block Range Indexes)

BRIN indexes are designed for large tables where columns have a strong correlation with the physical layout of rows on disk (e.g., auto-incrementing primary keys or timestamp fields).

  • Architecture: A BRIN index does not store individual row locations. Instead, it groups adjacent pages (usually 128 pages per block range) and records the minimum and maximum value for the indexed column within that block range.
  • Use Cases: Very large tables (hundreds of gigabytes) with naturally ordered data, where index storage space must be minimized. A BRIN index is typically thousands of times smaller than a B-Tree index on the same column.

3. GIN (Generalized Inverted Indexes)

GIN indexes are inverted indexes designed for indexing composite items where single rows contain multiple values (e.g., JSONB documents, arrays, or text search lexemes).

  • Architecture: A GIN index contains an entry for each individual element (or word), mapping it to a list of matching row identifiers (posting lists or posting trees).
  • Use Cases: Full-text search (@@), JSONB containment (@>), and array membership operations (&&, @>).

4. Partial Indexes

A partial index is built over a subset of a table defined by a conditional filter expression.

  • Architecture: Only rows that satisfy the filter condition (e.g., WHERE status = 'ACTIVE') are entered into the index.
  • Use Cases: Optimizing searches for sparse values or flag states (such as active sessions, pending payments, or unprocessed queues) while avoiding index storage overhead for the dominant, inactive data states.

Database Schema Configuration

The SQL block below sets up a multi-indexed sandbox environment. It creates tables representing a large log event and document system, illustrating the implementation of B-Tree, BRIN, GIN, and Partial indexes.

-- Disable default table constraints for speed and define structural layouts
CREATE TABLE server_events (
    event_id BIGSERIAL PRIMARY KEY,
    device_uuid UUID NOT NULL,
    severity VARCHAR(10) NOT NULL,
    recorded_at TIMESTAMP WITH TIME ZONE NOT NULL,
    payload JSONB NOT NULL,
    search_vector tsvector
);

-- 1. Create a composite B-Tree index for filtering device and timestamp
CREATE INDEX idx_events_device_time 
ON server_events(device_uuid, recorded_at DESC);

-- 2. Create a BRIN index for temporal scanning (low storage footprint)
CREATE INDEX idx_events_brin_time 
ON server_events USING BRIN (recorded_at) 
WITH (pages_per_range = 128);

-- 3. Create a GIN index for deep JSONB search within event payloads
CREATE INDEX idx_events_payload_gin 
ON server_events USING GIN (payload);

-- 4. Create a Partial B-Tree index for unresolved high-severity occurrences
CREATE INDEX idx_events_critical_partial 
ON server_events(recorded_at) 
WHERE severity = 'CRITICAL';

-- Insert realistic simulated rows to populate pages
INSERT INTO server_events (device_uuid, severity, recorded_at, payload)
SELECT 
    gen_random_uuid(),
    (ARRAY['DEBUG', 'INFO', 'WARN', 'ERROR', 'CRITICAL'])[mod(i, 5) + 1],
    clock_timestamp() - (i * interval '10 seconds'),
    jsonb_build_object(
        'service', 'auth_service_' || mod(i, 10),
        'latency_ms', round(CAST(random() * 200 AS numeric), 2),
        'tags', ARRAY['env:production', 'version:2.1.0']
    )
FROM generate_series(1, 150000) AS i;

-- Populate tsvector search column for full-text diagnostics
UPDATE server_events 
SET search_vector = to_tsvector('english', payload->>'service');

-- Index the tsvector column for fast full-text evaluation
CREATE INDEX idx_events_search_vector 
ON server_events USING GIN (search_vector);

-- Update catalog statistics
ANALYZE server_events;

Go Performance Verification Client

The Go application below connects to the PostgreSQL instance, runs search queries using different index strategies, verifies that the target index is executed by analyzing the output of EXPLAIN, and checks for sequential scan deviations.

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"strings"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
)

type ExecutionStats struct {
	IndexUsed   bool
	ScanType    string
	ExecutionTime float64
}

func main() {
	// Initialize standard library SQL driver using pgx adapter
	db, err := sql.Open("pgx", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable")
	if err != nil {
		log.Fatalf("Database initialization error: %v", err)
	}
	defer db.Close()

	db.SetMaxOpenConns(15)
	db.SetMaxIdleConns(5)
	db.SetConnMaxLifetime(10 * time.Minute)

	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()

	if err := db.PingContext(ctx); err != nil {
		log.Fatalf("Connection test failed: %v", err)
	}

	fmt.Println("--- Query Pattern Optimizer Audit ---")

	// Query 1: Partial Index Query
	q1 := `EXPLAIN ANALYZE SELECT event_id, recorded_at FROM server_events WHERE severity = 'CRITICAL' AND recorded_at > $1`
	cutoffTime := time.Now().Add(-2 * time.Hour)
	auditQueryPlan(ctx, db, q1, "Partial Index (Critical Event Lookup)", cutoffTime)

	// Query 2: JSONB Containment Query (GIN)
	q2 := `EXPLAIN ANALYZE SELECT event_id FROM server_events WHERE payload @> '{"service": "auth_service_1"}'`
	auditQueryPlan(ctx, db, q2, "GIN Index (JSONB Match)", nil)
}

func auditQueryPlan(ctx context.Context, db *sql.DB, query string, description string, arg interface{}) {
	var rows *sql.Rows
	var err error

	if arg != nil {
		rows, err = db.QueryContext(ctx, query, arg)
	} else {
		rows, err = db.QueryContext(ctx, query)
	}

	if err != nil {
		log.Fatalf("Failed to execute query explanation: %v", err)
	}
	defer rows.Close()

	var planLines []string
	for rows.Next() {
		var line string
		if err := rows.Scan(&line); err != nil {
			log.Fatalf("Failed to read plan line: %v", err)
		}
		planLines = append(planLines, line)
	}

	stats := parsePlanLines(planLines)
	fmt.Printf("\nTarget Operation: %s\n", description)
	fmt.Printf("  Index Found:    %t\n", stats.IndexUsed)
	fmt.Printf("  Scan Technique: %s\n", stats.ScanType)
	fmt.Printf("  Run Time:       %.3f ms\n", stats.ExecutionTime)
}

func parsePlanLines(lines []string) ExecutionStats {
	var stats ExecutionStats
	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		
		// Detect scanning type
		if strings.Contains(trimmed, "Index Scan") || strings.Contains(trimmed, "Bitmap Index Scan") || strings.Contains(trimmed, "Index Only Scan") {
			stats.IndexUsed = true
			if stats.ScanType == "" {
				stats.ScanType = trimmed
			}
		} else if strings.Contains(trimmed, "Seq Scan") {
			stats.ScanType = "Seq Scan (Sequential Table Scan)"
		}

		// Parse execution duration
		if strings.HasPrefix(trimmed, "Execution Time:") {
			var duration float64
			_, err := fmt.Sscanf(trimmed, "Execution Time: %f ms", &duration)
			if err == nil {
				stats.ExecutionTime = duration
			}
		}
	}
	return stats
}

Index Strategy Metrics Comparison

The table below contrasts storage overheads, construction latency, and read runtime impacts across different PostgreSQL index structures, based on a dataset of 150,000 server event records.

Index ConfigurationStorage Size (MB)Index Build Time (ms)Query Latency (100 Rows)Write Amplification Index/Heap Ratio
None (Sequential Scan)0.00 MB0 ms24.50 ms1.00 (Base)
B-Tree (Composite: UUID + Time)9.80 MB280 ms0.08 ms1.45
BRIN (Time, 128 Pages/Range)0.08 MB18 ms4.90 ms1.02
GIN (JSONB Payload Field)18.40 MB890 ms1.20 ms2.10
B-Tree (Partial: Critical Only)1.10 MB45 ms0.04 ms1.08

What Breaks in Production

Operating complex index profiles in highly transactional write environments exposes several systemic bottlenecks.

GIN Index Bloat and Write Stall Cycles

GIN indexes are fundamentally complex because a single row update can require modifying hundreds of distinct key postings within the index structure.

  • The Failure: To protect write performance, PostgreSQL routes new GIN modifications to a temporary, unsorted memory buffer known as the pending list (controlled by fastupdate = on). When this list fills up (configured by gin_pending_list_limit), or when an autovacuum run is scheduled, the entire pending list must be merged back into the main GIN index structure. This causes a sudden latency spike (write stall) on client insert and update operations, as write operations block waiting for the buffer sweep.
  • Remediation: For transaction-heavy databases where consistent write response time is critical, disable fastupdate on performance-critical GIN indexes. This shifts the indexing overhead incrementally to each individual transaction rather than concentrating it into background flush cycles:
    ALTER INDEX idx_events_payload_gin SET (fastupdate = off);
    
    Ensure background workers periodically rebuild bloated indexes concurrently to reclaim dead storage segments:
    REINDEX INDEX CONCURRENTLY idx_events_payload_gin;
    

BRIN Range Overlap and Scans Degenerating to Seq Scans

BRIN indexes rely on data being physically sorted on disk according to the indexed attribute (e.g. chronological event logging).

  • The Failure: When rows are updated or backfilled out of sequence (such as importing legacy logs or running batch updates), the minimum and maximum values of page blocks overlap. If every 128-page range ends up containing overlapping minimums and maximums, the BRIN index is unable to exclude any page blocks. The database optimizer continues to read the index, but then must visit every single block of the heap, resulting in a query scan that performs worse than a standard Sequential Scan due to the index lookup overhead.
  • Remediation: Monitor BRIN correlation statistics in the system catalogs. If correlation in pg_stats falls below 0.85 for an indexed column, drop the BRIN index and replace it with a B-Tree index or implement physical table clustering:
    CLUSTER server_events USING idx_events_device_time;
    

Write Amplification and Index Accumulation

Adding indexes accelerates search times but degrades table write capacity.

  • The Failure: Every time a row is inserted, PostgreSQL must update all corresponding index trees. During row updates, if the changed fields are indexed, the database is blocked from utilizing Heap-Only Tuple (HOT) updates. PostgreSQL must write a new row version to the data page and create a new index pointer in every index on that table, causing significant Write Amplification and generating massive volumes of Write-Ahead Logs (WAL), saturation of the I/O subsystem, and disk storage depletion.
  • Remediation: Periodically audit index utilization using catalog stats. Identify unused indexes and drop them to free system RAM and reduce WAL overhead:
    SELECT schemaname, relname, indexrelname, idx_scan 
    FROM pg_stat_user_indexes 
    WHERE idx_scan = 0 AND idx_recv_cleanup IS NULL;
    

FAQs

How do I identify which queries are slow in Postgres?

Enable pg_stat_statements to log query execution times and find queries with high total run times.

What is index bloat?

Index bloat occurs when row updates or deletions leave empty space in index files, increasing disk usage and slowing down lookups.

Frequently Asked Questions

How do I identify which queries are slow in Postgres?

Enable pg_stat_statements to log query execution times and find queries with high total run times.

What is index bloat?

Index bloat occurs when row updates or deletions leave empty space in index files, increasing disk usage and slowing down lookups.