Patterns — Optimizing SQL Performance
Contents
- Reading EXPLAIN ANALYZE
- Finding the workload's worst queries
- Index recipes
- N+1 rewrites
- Keyset pagination
- Materialized views
- Dialect deviations (MySQL, SQLite)
- Gotchas
Reading EXPLAIN ANALYZE
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total_cents FROM orders
WHERE user_id = 42 AND created_at >= '2026-01-01';Read inner-most node first; the fix usually targets the node with the largest
actual time. Checklist per node:
| Signal | Meaning | Action |
|---|---|---|
Seq Scan + Rows Removed by Filter huge |
selective filter, no usable index | add index (see recipes) |
rows=3 estimated vs rows=30000 actual |
stale statistics | ANALYZE orders; then re-plan |
Sort Method: external merge Disk |
sort spills to disk | index matching ORDER BY, or raise work_mem for the session |
Nested Loop with huge outer side |
join misestimate | fix stats; check join column indexes |
Heap Fetches high on Index Only Scan |
visibility map stale | VACUUM orders; |
Finding the workload's worst queries
sql
-- requires: CREATE EXTENSION pg_stat_statements;
SELECT calls, mean_exec_time::int AS mean_ms,
(calls * mean_exec_time)::int AS total_ms, query
FROM pg_stat_statements
ORDER BY calls * mean_exec_time DESC
LIMIT 10;Optimize by total_ms (aggregate cost), not by single-query time.
Index recipes
sql
-- Equality-then-range composite (rule 3):
CREATE INDEX orders_user_created_idx ON orders (user_id, created_at);
-- Covering index for an index-only scan:
CREATE INDEX orders_user_created_cov_idx
ON orders (user_id, created_at) INCLUDE (total_cents);
-- Expression index (query must use the same expression):
CREATE INDEX users_email_lower_idx ON users (lower(email));
-- Partial index for a hot subset:
CREATE INDEX orders_pending_idx ON orders (created_at)
WHERE status = 'pending';
-- Trigram index for LIKE '%term%':
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX users_name_trgm_idx ON users USING gin (display_name gin_trgm_ops);
-- Build without blocking writes (production):
CREATE INDEX CONCURRENTLY ...;
-- Find unused indexes:
SELECT indexrelid::regclass, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND indexrelid::regclass::text NOT LIKE '%_pkey';N+1 rewrites
python
# ❌ one query per user
for uid in user_ids:
cur.execute("SELECT * FROM orders WHERE user_id = %s", (uid,))
# ✅ one query, group in app code
cur.execute(
"SELECT user_id, id, total_cents FROM orders WHERE user_id = ANY(%s)",
(user_ids,))Aggregate-per-parent variant in one round trip:
sql
SELECT u.id, COALESCE(SUM(o.total_cents), 0) AS spend
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.id = ANY($1)
GROUP BY u.id;Keyset pagination
sql
-- page 1
SELECT id, created_at FROM orders
ORDER BY created_at DESC, id DESC LIMIT 20;
-- next page: pass the last row's (created_at, id)
SELECT id, created_at FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC LIMIT 20;
CREATE INDEX orders_created_id_idx ON orders (created_at DESC, id DESC);Tie-break with id is mandatory — created_at alone skips/duplicates rows on
equal timestamps. Tradeoff: no random page jumps.
Materialized views
sql
CREATE MATERIALIZED VIEW daily_revenue AS
SELECT created_at::date AS day, SUM(total_cents) AS revenue_cents
FROM orders GROUP BY created_at::date;
CREATE UNIQUE INDEX daily_revenue_day_idx ON daily_revenue (day);
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue; -- needs the unique indexDialect deviations (MySQL, SQLite)
- MySQL:
EXPLAIN ANALYZE(8.0.18+); no partial/expression indexes before 8.0.13 (functional indexes); useperformance_schemainstead ofpg_stat_statements; noINCLUDE— add columns to the index key. - SQLite:
EXPLAIN QUERY PLAN; runANALYZE;to populate stats; partial indexes supported; no concurrent index builds.
Gotchas
CREATE INDEX(withoutCONCURRENTLY) takes a write lock on the table.EXPLAINwithoutANALYZEshows estimates only — plans, not reality.- Casts defeat indexes:
WHERE id::text = $1seq-scans; cast the parameter instead. - Low-selectivity indexes (boolean flags) are usually ignored by the planner — a partial index on the rare value works.
random_page_costdefault (4.0) is tuned for spinning disks; on SSDs the planner may wrongly prefer seq scans — typical SSD setting is 1.1.- After bulk loads, run
ANALYZE(andVACUUM) before judging any plan. - ORMs hide N+1: enable SQL logging before believing "the query is slow" — often it's 500 queries.