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
NodeWhat it doesWhen it is a problem
Seq ScanReads the whole tableOn a large table with a selective filter
Index ScanSeeks the index, then fetches rowsMany scattered fetches can cost more than one scan
Index Only ScanAnswers from the index aloneNeeds the visibility map to be current
Bitmap Heap ScanCollects index hits, then reads pages in physical orderRarely; it is the usual choice for medium selectivity
Nested LoopProbes the inner side once per outer rowWhen the outer side is large and the inner side is not indexed
Hash JoinBuilds a hash of the smaller sideSpills to disk when the build side exceeds memory
Merge JoinMerges two ordered inputsWasteful when both inputs need an explicit sort first
SortOrders rowsWatch 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.
  • OR across different columns often defeats a single index seek; a UNION ALL of 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.
  • LIMIT only helps when the plan can stop early — with ORDER BY on 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 planLikely causeFirst fix
Estimated 1 row, actual 500000Stale or missing statisticsANALYZE, or extended statistics over correlated columns
Seq Scan on a huge tableNo usable index, or the predicate is not selectiveAdd or correct an index, and make the predicate sargable
Sort Method: external mergeThe sort does not fit in work_memIndex the sort order, or reduce rows and columns before sorting
Nested Loop over millions of rowsBad row estimate on the outer sideFix statistics; treat a planner hint as a last resort
Rows Removed by Filter: 999999The index is not selective enough for this queryAdd 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 ANALYZE yourself 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.

Subqueries, CTEs and window functions Transactions and isolation levels

Last refreshed 2026-09-18.