Database
May 2026•13 min read

PostgreSQL Query Optimization & Indexing Strategies

From EXPLAIN (ANALYZE, BUFFERS) to composite B-Trees and connection pool tuning

AP

Ashish Pal

Full Stack Developer

PostgreSQL Query Optimization & Indexing Strategies

Executive Takeaways & Key Concepts

  • Interpret EXPLAIN (ANALYZE, BUFFERS) to detect disk buffer hits vs shared cache reads.
  • Construct composite B-Tree indexes adhering to the Equality-Range-Sort (ESR) rule.
  • Use partial indexes (WHERE clauses) to reduce index disk footprint by up to 80%.
  • Tune PgBouncer transaction pooling to prevent backend PostgreSQL process contention.

1. Mastering EXPLAIN (ANALYZE, BUFFERS)

Diagnosing exact disk I/O bottlenecks and memory spills

Optimizing SQL queries without reading execution plans is like debugging without a stack trace. Running `EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON)` runs the query and outputs actual timing, memory buffer hits, and node types.

When reading the output, focus on 'Buffers: shared hit=128 read=450'. 'Hit' means data was retrieved directly from the RAM buffer pool; 'read' means PostgreSQL had to issue a synchronous disk read. High disk reads directly correlate with latency spikes during traffic bursts.

Golden Tip: Watch out for 'Seq Scan on table_name' on tables with over 50,000 rows. A Sequential Scan forces PostgreSQL to read every raw page from disk into memory.

2. The Equality, Sort, Range (ESR) Indexing Rule

How to order columns in composite B-Trees for maximum index selectivity

When creating a multi-column index, column order matters critically. Follow the ESR rule:

1. Equality columns first (`status = 'ACTIVE'`, `tenant_id = 123`).

2. Sort / Order By columns second (`ORDER BY created_at DESC`).

3. Range filter columns last (`salary BETWEEN 50000 AND 120000` or `created_at >= '2026-01-01'`).

migrations/20260501_optimize_job_searches.sql
sql
-- BAD: Column order prevents efficient index range traversal
-- CREATE INDEX idx_jobs_bad ON jobs (created_at, status, salary);

-- GOOD: Adheres strictly to the Equality -> Sort -> Range rule
CREATE INDEX CONCURRENTLY idx_jobs_optimal 
ON jobs (company_id, status, created_at DESC, salary_min);

-- PARTIAL INDEX: Only indexes rows that are actively queryable
-- Reduces index size from 850MB to 42MB on a 5M row table!
CREATE INDEX CONCURRENTLY idx_jobs_published_active
ON jobs (company_id, created_at DESC)
WHERE status = 'published' AND is_deleted = false;

3. Covering Indexes with the INCLUDE Clause

Achieving Index-Only Scans without table heap access

In a standard Index Scan, PostgreSQL traverses the B-Tree to find matching row pointers (TIDs), then fetches the actual table heap page to retrieve non-indexed columns.

By adding the `INCLUDE` clause, secondary columns are stored in the leaf nodes of the index without expanding the B-Tree branching complexity. This allows PostgreSQL to satisfy the entire query via an Index-Only Scan without touching the table heap at all.

migrations/20260502_covering_indexes.sql
sql
-- Satisfies: SELECT title, location, salary_min FROM jobs WHERE company_id = ? ORDER BY created_at DESC
CREATE INDEX CONCURRENTLY idx_jobs_covering
ON jobs (company_id, created_at DESC)
INCLUDE (title, location, salary_min);

4. PgBouncer Connection Pooling & Sizing Formula

Why fewer PostgreSQL database connections mean higher throughput

A common mistake is assigning 500+ direct connections to PostgreSQL. Each PostgreSQL backend process consumes ~10MB RAM and causes context-switching thrashing on Linux CPU cores.

The ideal connection pool size follows PostgreSQL's formula: `connections = (2 * CPU_CORES) + DISK_SPINDLES`. On an 8-core server, 18-25 backend connections running behind PgBouncer in transaction mode can easily handle 5,000+ client requests per second.

#PostgreSQL#SQL Tuning#Indexing#Backend#Performance#Database

More Engineering Notes

View All
Connect