Database Query Execution Plans
"The index exists, why is this query still slow?" is one of the most common database escalations — and the answer is almost always sitting in EXPLAIN ANALYZE output that nobody read past the first line. A query plan is not a black box; it's a tree of nodes, each reporting exactly what it estimated, what it actually did, and how those two numbers diverge.
This guide teaches the plan tree itself: how to read it bottom-up, what each scan and join node type actually does, why cost is not milliseconds, and the single most useful signal in any plan — the gap between estimated and actual row counts. Then it traces one real query where a perfectly good index gets ignored, and shows exactly why. For the index types and structures a plan chooses between, see Database Indexing Explained.
Reading a Plan Tree
EXPLAIN prints a query's chosen plan as an indented tree — but execution runs the opposite direction from how you read it. The most deeply indented nodes (scans of actual tables) execute first; their output feeds upward into join and aggregate nodes above them, which feed further upward, until the topmost node produces the final result set. Read the indentation as a call stack, not a sequence of steps.
Each node reports cost as startup_cost..total_cost — an estimate of relative work in arbitrary planner units, not milliseconds — plus estimated rows (how many rows the planner expects that node to return) and width (estimated average row size in bytes). Run EXPLAIN ANALYZE instead of plain EXPLAIN and the query actually executes, adding actual time in real milliseconds, actual rows, and loops (how many times a node ran — relevant for a node nested inside another that executes it once per outer row).
Quick reference
- Indentation = call stack: the deepest nodes execute first, feeding upward.
- cost is an arbitrary relative unit derived from planner constants — never read it as milliseconds.
- EXPLAIN alone never runs the query; EXPLAIN ANALYZE does, including any side effects of a write query (wrap those in a transaction and roll back).
- loops > 1 means that node executed multiple times — its actual time is per-execution, so multiply by loops for its real total contribution.
- Read a plan bottom-up for execution order, but scan estimated-vs-actual rows top-down for where a plan first goes wrong.
Remember this
A plan's indentation shows execution order bottom-up, not a numbered sequence — and cost is a relative planner unit, never a millisecond estimate.
Scan and Join Node Types
Seq Scan reads every row in a table — correct and often fastest when a query needs a large fraction of the table, since sequential I/O beats the random I/O of chasing an index. Index Scan uses an index to find matching rows, then fetches each one from the table heap — efficient for high-selectivity queries (a small fraction of the table), expensive if it forces many scattered heap fetches. Index Only Scan answers entirely from the index itself, skipping the heap fetch — only possible when every requested column is in the index and Postgres's visibility map confirms the relevant pages have no pending vacuum work. Bitmap Heap Scan (paired with Bitmap Index Scan) builds a bitmap of matching pages from the index, then visits those pages in physical order — a middle ground for medium-selectivity queries where a plain Index Scan's random I/O would be worse than this batched approach.
For combining two row sets, Nested Loop scans the inner side once per row of the outer side — cheap when the outer set is small and the inner side has a fast index lookup, disastrous if the outer set is large. Hash Join builds an in-memory hash table from one side and probes it with the other — strong for large, unsorted inputs. Merge Join requires both inputs already sorted on the join key and merges them in one pass — efficient when that sort order already exists (often from an index), wasteful if the database has to sort both sides first just to use it.
Quick reference
- Seq Scan: reads the whole table — right choice when selectivity is low (many rows match) or the table is small.
- Index Scan: index lookup + heap fetch per row — right choice at high selectivity, weak at medium-to-low selectivity due to random I/O.
- Index Only Scan: no heap fetch at all — needs every selected column covered by the index and an up-to-date visibility map.
- Bitmap Heap/Index Scan: batches heap access by physical page order — the planner's answer for selectivity that's too high for a plain Index Scan, too low for a Seq Scan.
- Nested Loop / Hash Join / Merge Join each fit a different combination of input size, sort order, and available indexes — none is universally 'the fast one.'
Remember this
Every scan and join node is a trade-off tuned to selectivity, input size, and available sort order — there is no single 'correct' node type independent of the data it's actually touching.
Estimated vs Actual Rows: The Most Useful Signal in Any Plan
The planner chooses a plan based entirely on its estimates — cost, rows, and width — computed from table statistics gathered by ANALYZE (run automatically by autovacuum, though it can lag on rapidly changing tables). When a node's actual row count differs wildly from its estimate, everything the planner decided above that node in the tree was decided on bad information — a join node expecting 10 rows from below it might pick a Nested Loop that becomes catastrophic when 50,000 rows actually show up.
A large gap has a short list of usual causes: stale statistics (the table changed significantly since the last ANALYZE), correlated columns (the planner assumes columns are independent by default, so WHERE region = 'us' AND warehouse = 'us-east-1' may badly misjudge combined selectivity — CREATE STATISTICS can teach it the correlation), or a non-sargable predicate (wrapping an indexed column in a function, like WHERE lower(email) = $1 against a plain index on email, defeats the index unless a matching expression index exists).
Quick reference
- A 10x+ gap between estimated and actual rows on any node is worth investigating before anything else in the plan.
- Fix stale statistics with a manual ANALYZE table_name, or check why autovacuum's autoanalyze isn't keeping up on that table.
- Correlated WHERE conditions may need CREATE STATISTICS (extended statistics) so the planner stops assuming independence.
- A function wrapped around an indexed column needs a matching expression index, or the planner can't use the plain one at all.
- The gap compounds upward: a bad estimate at a leaf scan can produce a badly-chosen join strategy several levels above it.
- Fix the query and its statistics before reaching for a scaling technique — see Database Scaling Techniques for when the bottleneck is genuinely architectural instead.
Remember this
The single highest-value thing to check in any EXPLAIN ANALYZE output is where estimated rows and actual rows diverge — that's where the planner's decision-making went wrong, and everything built on top of it inherited the error.
Recover One Case Where the Planner Skips a Good Index
Trigger. A team adds CREATE INDEX idx_orders_status ON orders(status) expecting WHERE status = 'refunded' to get fast. Symptom. EXPLAIN still shows Seq Scan on orders, not Index Scan using idx_orders_status — the index exists, is valid, and is simply not being used. Root mechanism. Statistics show refunded orders are 22% of the table — well past the selectivity point where a Seq Scan's sequential I/O legitimately beats an Index Scan's random heap fetches for that many matching rows. The planner isn't broken; it correctly priced both options and Seq Scan actually won.
Recovery: confirm this with EXPLAIN ANALYZE — if actual rows at the Seq Scan node roughly matches the estimate, the planner's choice was informed and correct, and the fix is not "force the index" but reconsidering the query itself (a partial index on just the rare status values, or restructuring the workload to not need this on the hot path). Verification: test SET enable_seqscan = off in a throwaway session and compare the forced Index Scan's actual time against the original Seq Scan's — often the Seq Scan is genuinely faster, proving the index was never the fix this query needed. Prevention: before adding an index for a specific value, check that value's actual selectivity in the table first — an index on a common value is dead weight, not a missing optimization.
Quick reference
- Trigger: an index exists for a WHERE clause, but the planner still chooses Seq Scan.
- Symptom: EXPLAIN shows no use of the new index at all.
- Root mechanism: selectivity is high enough that sequential I/O legitimately costs less than the index's random I/O.
- Recovery: verify with EXPLAIN ANALYZE that estimated and actual rows agree — if so, the planner is right, not broken.
- Prevention: check a value's actual selectivity before indexing for it — a common-value index is wasted maintenance overhead, not a missing win.
Remember this
A planner that skips an existing index isn't necessarily malfunctioning — verify selectivity with EXPLAIN ANALYZE before assuming the fix is forcing index use rather than rethinking the query or the index's scope.
Key takeaway
A query plan is a tree you read bottom-up for execution order and top-down for where estimates first diverge from reality. Scan and join node types are each suited to a specific selectivity and input-size profile, not ranked fast-to-slow. The single most valuable check in any EXPLAIN ANALYZE output is the gap between estimated and actual rows — and a planner that skips your new index has often done the arithmetic correctly, not failed to notice the index exists.
Practice (30 min): create a table with a skewed column (one value covering ~25% of rows, another covering ~0.1%). Index the column, then run EXPLAIN ANALYZE filtering on each value separately. Confirm the common value produces a Seq Scan and the rare value produces an Index Scan — the same index, two different plans, both correct for their selectivity. Then run ANALYZE after bulk-inserting new skewed data and compare the plan before and after to see stale statistics change the planner's choice. Pass when you can explain, for each plan, exactly which number in the output justified the planner's decision.
Related Articles
Explore this topic