Query Planning
Date: 2026-08-17
The database decides how to execute your query, and reading that decision is the only reliable way to know why one is slow. Guessing at the cause of a slow query is the most common waste of time in backend work.
The query planner takes a declarative query, enumerates ways of executing it, estimates the cost of each using statistics about the data, and picks one. The execution plan is that choice, and every database can show it to you.
EXPLAIN SELECT ... -- the plan
EXPLAIN ANALYZE SELECT ... -- the plan, plus
-- actual timingsUse ANALYZE — it runs the query and reports what really happened, which is the only way to see where estimates were wrong.
What a plan is made of
Read it as a tree, innermost first:
Hash Join (cost=… rows=200)
├─ Seq Scan on orders (rows=100000)
│ Filter: total > 50
└─ Hash
└─ Index Scan using customers_pkey
(rows=200)
Seq Scan read every row
Index Scan walk the index, fetch rows
Index Only Scan the index had everything
Bitmap Scan many index hits, batched
Nested Loop for each outer row, probe inner
Hash Join build a hash of one side
Merge Join both sides sorted, zip them
Sort explicit sort — memory or disk
Aggregate GROUP BY, COUNT
The three numbers that matter
1 ESTIMATED vs ACTUAL rows
est 200, actual 500,000
→ statistics are stale or the
predicate is unusual
→ the planner chose badly BECAUSE
it was misinformed
2 Seq Scan on a large table
with a selective filter
→ a missing or unusable index
3 Sort or Hash spilling to disk
"external merge Disk: 45MB"
→ raise work memory, or index to
avoid the sort
The first is the highest-value diagnostic. A planner picking a bad strategy is almost always a planner with wrong estimates, and the fix is to refresh statistics rather than to rewrite the query.
Why a sequential scan is sometimes right
SELECT * FROM orders WHERE status = 'paid';
-- 95% of rows are 'paid'Using an index here would be slower. The database would walk the index and then fetch nearly every row individually, in index order, which is worse than reading the table in physical order. The planner is correct to ignore the index.
This is why “it’s not using my index” is often not a bug. Check selectivity before assuming — Indexing.
Statistics, and why they go stale
The planner estimates from sampled statistics — row counts, distinct values, distributions. After a bulk load, a large delete, or a big data shift, those become wrong and the plans follow.
ANALYZE table_name; -- refresh statisticsA query that was fast for months and is suddenly slow, with no code change, is usually stale statistics or a plan flip caused by data growth crossing a threshold.
Common causes of slow queries, in order
1 no index on the filter/join column
2 a function on an indexed column
3 N+1 — many small queries, not one
slow one
4 SELECT * pulling columns you discard
5 OFFSET pagination deep into a table
(OFFSET 100000 reads and discards
100,000 rows) — use keyset pagination
6 an unbounded query with no LIMIT
7 stale statistics
See: N+1 Queries · Query Performance for the rewrites
Number 3 is the one that never appears in a slow-query log, because each individual query is fast. It shows up as a slow page.
Reading it as a non-DBA
You don’t need to tune a planner. You need to answer three questions:
Is it scanning something large? → index it
Are estimates wildly off? → ANALYZE
Is it sorting or spilling to disk? → index the
ORDER BY
That covers most of what a web developer will ever encounter, and it’s the difference between a fix and a guess.
Where the abstraction leaks
- Planners differ. Postgres, MySQL and SQLite make different choices, and advice doesn’t transfer cleanly
- Prepared statements can produce generic plans that are worse than one tailored to the parameters
- ORMs generate queries you didn’t write. Log the actual SQL before concluding anything — the query you’re debugging may not be the query you think — N+1 Queries
- Warehouses plan differently, with columnar storage and partition pruning rather than row indexes.
EXPLAINis still the tool; the vocabulary changes