Query tuning and execution plans
How to read EXPLAIN output, which scan and join strategies matter, why stale statistics ruin a plan, and how to write predicates an index can actually use.
Reading a plan
The planner is not guessing at random: it costs alternatives using statistics and picks the cheapest. Reading its plan is the difference between tuning the real bottleneck and tuning what you assume is slow.
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.name, SUM(o.total) AS spent
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.placed_at >= now() - interval '30 days'
GROUP BY c.name
ORDER BY spent DESC
LIMIT 10;
-- Limit (cost=1820.4..1820.5 rows=10) (actual time=41.2..41.3 rows=10)
-- -> Sort (cost=1820.4..1822.9 rows=1000) (actual time=41.2..41.2 rows=10)
-- Sort Key: (sum(o.total)) DESC
-- -> HashAggregate (rows=1000) (actual time=30.1..38.4 rows=812)
-- Group Key: c.name
-- -> Hash Join (rows=10000) (actual time=3.0..19.9 rows=9412)
-- Hash Cond: (o.customer_id = c.id)
-- -> Seq Scan on orders o (actual rows=9412)
-- Filter: (placed_at >= (now() - '30 days'::interval))
-- -> Hash (actual rows=5000)
-- -> Seq Scan on customers c| Node | What it does | When it is a problem |
|---|---|---|
| Seq Scan | Reads the whole table | On a large table with a selective filter |
| Index Scan | Seeks the index, then fetches rows | Many scattered fetches can cost more than one scan |
| Index Only Scan | Answers from the index alone | Needs the visibility map to be current |
| Bitmap Heap Scan | Collects index hits, then reads pages in physical order | Rarely; it is the usual choice for medium selectivity |
| Nested Loop | Probes the inner side once per outer row | When the outer side is large and the inner side is not indexed |
| Hash Join | Builds a hash of the smaller side | Spills to disk when the build side exceeds memory |
| Merge Join | Merges two ordered inputs | Wasteful when both inputs need an explicit sort first |
| Sort | Orders rows | Watch for Sort Method: external merge, which means spilling to disk |
Compare estimated rows with actual rows. A large gap means the planner chose a strategy for a table that does not exist, and everything downstream of that estimate is suspect.
Predicates an index can use
-- not sargable: the column is wrapped in a function, so no seek is possible
SELECT * FROM orders WHERE date_trunc('day', placed_at) = '2026-09-01';
-- sargable: a half-open range on the bare column
SELECT * FROM orders
WHERE placed_at >= '2026-09-01' AND placed_at < '2026-09-02';
-- not sargable: a leading wildcard cannot use a B-tree index
SELECT * FROM products WHERE name LIKE '%lamp%';
-- sargable: prefix match, plus a functional index for case-insensitive search
SELECT * FROM products WHERE name LIKE 'lamp%';
CREATE INDEX idx_products_lower_name ON products (lower(name));
SELECT * FROM products WHERE lower(name) = 'desk lamp';
-- arithmetic belongs on the constant side
SELECT * FROM products WHERE price > 100 / 1.2;
SELECT * FROM users WHERE id = 42; -- id is an integer; do not pass '42'- Leave the column bare on the left of the comparison and push every transformation onto the constant.
ORacross different columns often defeats a single index seek; aUNION ALLof two indexed queries can be faster.- Select only the columns you need: a covering index can answer the query without touching the table at all.
LIMITonly helps when the plan can stop early — withORDER BYon an unindexed column the engine still sorts every row first.
⚠️
Rewriting a predicate changes the answer unless the boundaries are right.
date_trunc('day', x) = d is a half-open range [d, d + 1), so write it with >= and <. BETWEEN is inclusive on both ends and will double-count the boundary.Statistics and join strategies
-- the planner decides with statistics, so keep them fresh and specific
ANALYZE orders;
CREATE STATISTICS orders_customer_status (dependencies)
ON customer_id, status FROM orders;
-- see how many rows the planner believes a predicate matches
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
-- disable a strategy only to test a hypothesis, never in production
SET enable_seqscan = off;
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
RESET enable_seqscan;| Symptom in the plan | Likely cause | First fix |
|---|---|---|
| Estimated 1 row, actual 500000 | Stale or missing statistics | ANALYZE, or extended statistics over correlated columns |
| Seq Scan on a huge table | No usable index, or the predicate is not selective | Add or correct an index, and make the predicate sargable |
| Sort Method: external merge | The sort does not fit in work_mem | Index the sort order, or reduce rows and columns before sorting |
| Nested Loop over millions of rows | Bad row estimate on the outer side | Fix statistics; treat a planner hint as a last resort |
| Rows Removed by Filter: 999999 | The index is not selective enough for this query | Add a composite or partial index that matches the predicate |
- Autovacuum keeps statistics reasonably fresh on healthy systems; bulk loads and migrations can outrun it, so run
ANALYZEyourself afterwards. - A partial index (
WHERE status = 'open') is far smaller and faster when almost every query only ever wants the small subset. - Change one thing, measure again, and keep the before-and-after numbers — tuning without measurements is guesswork with extra steps.
FAQ
Is a Seq Scan always bad?
No. Reading a small table, or a large table where the filter keeps most rows, is genuinely cheaper than following an index. Judge by rows read and by elapsed time, not by the node name.
How do I fix a bad plan?
In order: refresh statistics, make the predicates sargable, add or adjust an index, reduce the rows and columns the query touches, then restructure the query. Planner hints come last because they freeze today's good plan into tomorrow's bad one.
Related
Subqueries, CTEs and window functions Transactions and isolation levels
Last refreshed 2026-09-18.