Skip to main content

PostgreSQL Query Optimizer

Master EXPLAIN ANALYZE, choose the right index type, find slow queries with pg_stat_statements, and systematically eliminate bottlenecks in PostgreSQL.

✓ EXPLAIN ANALYZE reading ✓ Index type selection ✓ pg_stat_statements ✓ N+1 query fixes

EXPLAIN ANALYZE — Reading the Output

-- Always use EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) for full detail
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id
ORDER BY order_count DESC
LIMIT 20;

-- Reading the output:
-- "Seq Scan on users" → full table scan → may need index on created_at
-- "cost=0.00..150.25" → estimated cost units (not ms)
-- "actual time=0.042..12.340" → real ms for first row..all rows
-- "rows=1234 width=64" → estimated rows
-- "actual rows=1289 loops=1" → real rows (compare to estimate)
-- "Buffers: shared hit=45 read=12" → 45 from cache, 12 from disk
-- "Planning Time: 0.5ms" → query planning overhead
-- "Execution Time: 12.8ms" → total execution time

Index Type Selection Guide

Index Type Best For Operators
B-tree (default)Most column types, range queries=, <, >, BETWEEN, LIKE 'abc%'
HashHigh-cardinality equality only=
GINJSONB, arrays, full-text search@>, ?, @@, ANY
GiSTGeometry, ranges, full-text&&, @>, <<|
BRINLarge tables with natural order=, <, > on ordered data

Common Index Patterns

-- Partial index (smaller, faster for frequent filter patterns)
CREATE INDEX idx_orders_pending
  ON orders (created_at)
  WHERE status = 'pending';   -- only indexes pending rows

-- Functional / expression index
-- BAD: WHERE LOWER(email) = $1 won't use regular index on email
CREATE UNIQUE INDEX idx_users_email_lower
  ON users (LOWER(email));    -- now LOWER(email) queries use this index

-- Composite index — column order matters
-- Serves: WHERE user_id = ? AND created_at > ?
-- Serves: WHERE user_id = ? (leftmost prefix)
-- Does NOT serve: WHERE created_at > ? (skips leftmost column)
CREATE INDEX idx_orders_user_date
  ON orders (user_id, created_at DESC);

-- Covering index (includes extra columns to avoid table lookup)
CREATE INDEX idx_users_email_covering
  ON users (email) INCLUDE (id, name);

-- GIN for JSONB queries
CREATE INDEX idx_products_metadata
  ON products USING GIN (metadata);
-- Enables: WHERE metadata @> '{"category": "electronics"}'

-- Full-text search with pg_trgm (substring/fuzzy search)
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_users_name_trgm
  ON users USING GIN (name gin_trgm_ops);
-- Enables: WHERE name LIKE '%john%'  or ILIKE

Finding Slow Queries with pg_stat_statements

-- Enable (add to postgresql.conf then restart):
-- shared_preload_libraries = 'pg_stat_statements'
-- pg_stat_statements.track = all

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Top 10 slowest queries by average execution time
SELECT
  left(query, 100) AS query_snippet,
  calls,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  round(total_exec_time::numeric, 2) AS total_ms,
  rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;

-- Top 10 by total time (most impactful to optimize)
SELECT
  left(query, 100) AS query_snippet,
  calls,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  round(total_exec_time::numeric / 1000, 2) AS total_sec
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- Reset stats before a new performance test
SELECT pg_stat_statements_reset();

N+1 Query Fix with JSON Aggregation

-- BAD (N+1 pattern): 1 query for users + N queries for orders
-- SELECT * FROM users WHERE active = true;
-- For each user: SELECT * FROM orders WHERE user_id = $1;

-- GOOD: single query with JSON aggregation
SELECT
  u.id,
  u.name,
  u.email,
  COALESCE(json_agg(
    json_build_object(
      'id', o.id,
      'amount', o.total_amount,
      'created_at', o.created_at
    ) ORDER BY o.created_at DESC
  ) FILTER (WHERE o.id IS NOT NULL), '[]') AS orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.active = true
GROUP BY u.id, u.name, u.email;

-- Result: each user row includes a JSON array of their orders
-- One round-trip instead of N+1

Optimization Checklist

Frequently Asked Questions

How do I read a PostgreSQL EXPLAIN ANALYZE output?▼

EXPLAIN ANALYZE runs the query and shows the actual execution plan. Key metrics to read: 'Seq Scan' means full table scan — usually needs an index. 'rows' shows estimated vs actual rows (large discrepancy means stale statistics — run ANALYZE). 'cost' shows estimated I/O units (not milliseconds). 'actual time' shows real execution time in milliseconds. 'Buffers: hit' shows pages read from cache vs 'read' from disk. 'Nested Loop' with large row counts often indicates a missing index on the join condition.

When should I use a composite (multi-column) index?▼

Use a composite index when your WHERE clause frequently filters on multiple columns together. The column order matters: put the most selective column (highest cardinality) first, or put the equality columns before range columns. A composite index on (user_id, created_at) serves queries like WHERE user_id = 5 AND created_at > '2025-01-01' efficiently. It also serves queries that filter only on user_id (leftmost prefix). It does NOT efficiently serve queries on only created_at — a separate index is needed for that.

What is the difference between a B-tree, Hash, GIN, and GiST index?▼

B-tree (default) handles equality, range comparisons (<, >, BETWEEN), and ORDER BY — use for most cases. Hash indexes handle only equality comparisons and are faster for exact matches on high-cardinality columns. GIN (Generalized Inverted Index) is optimal for multi-value types: JSONB, arrays, and full-text search tsvector — use for @>, ?, and @@ operators. GiST (Generalized Search Tree) handles geometric types, range types, and full-text — more flexible than GIN but slower for pure text search. BRIN is for very large tables with naturally ordered data (timestamps on append-only tables).

Why is my query slow even though it has an index?▼

Common reasons an index is not used: (1) Function wrapping the indexed column — WHERE LOWER(email) = ... prevents index use; create a functional index instead. (2) LIKE with leading wildcard — WHERE name LIKE '%john%' can't use a B-tree index; use GIN with pg_trgm for substring search. (3) Implicit type cast — comparing an integer column to a string literal causes a cast that bypasses the index. (4) Low selectivity — if the index would return more than 5-15% of rows, the planner may prefer a seq scan. (5) Stale statistics — run ANALYZE to update planner statistics.

What is pg_stat_statements and how do I use it to find slow queries?▼

pg_stat_statements is a PostgreSQL extension that tracks execution statistics for every distinct query. Enable it by adding shared_preload_libraries = 'pg_stat_statements' to postgresql.conf and running CREATE EXTENSION pg_stat_statements. Query it with SELECT query, calls, mean_exec_time, total_exec_time FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 20 to find the slowest queries by average execution time. Reset statistics with SELECT pg_stat_statements_reset() before a performance test.

How do I avoid N+1 query problems in PostgreSQL?▼

N+1 occurs when code executes one query to fetch N records, then N additional queries to fetch related data. Fix with JOINs or subqueries that fetch everything in one round trip. For ORM-based apps: use eager loading (Prisma: include, Sequelize: include, ActiveRecord: includes). For complex cases, use lateral joins or JSON aggregation: SELECT u.*, json_agg(o.*) AS orders FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id returns each user with their orders array in a single query.

Share this tool: X / Twitter LinkedIn
✓ 60,000+ developers use DevPlaybook ★★★★★ 4.8 / 5 on Gumroad ✓ MIT licensed · Instant download ✓ No login required

Related Products

on Gumroad

You Might Also Need

More Free Tools

Enjoying this tool? Get more like it.

Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.

DevPlaybook Pro 7 days free

Unlock AI-Powered Dev Tools

  • ⚡ AI Code Review, Doc Generator & SQL Builder
  • ⚡ All premium templates & early access
  • ⚡ Member discounts on Gumroad products
Start Free Trial →

$9/mo after trial · Cancel anytime

Want the full toolkit?

Get DevPlaybook Pro

Every template, guide, boilerplate, and automation script in one bundle. 13 premium products — grab them all at once and save big.

$79
$241 separately Save $162 (67% off)
Get DevPlaybook Pro — $79 →

MIT licensed · Instant download · No subscription

See what's included → Browse all deals →