Chapter 14 · Indexes and Query Execution
Reading Query Plans with EXPLAIN
A query plan is the optimizer’s executable hypothesis. Reading it lets you verify whether predicates, joins, sorting, and indexes are working as intended instead of tuning from intuition alone.
Learning outcomes
Turn plans into evidence
Distinguish SQLite EXPLAIN from EXPLAIN QUERY PLAN.
Recognize SCAN, SEARCH, COVERING INDEX, and temporary B-tree indicators.
Read nested-loop order for joins and identify repeated inner work.
Interpret PostgreSQL plan nodes, costs, row estimates, and actual measurements.
Compare plans before and after a targeted change without depending on unstable text formatting.
Three related tools
| Tool | What it shows | Primary use |
|---|---|---|
| SQLite EXPLAIN QUERY PLAN | High-level table/index access, join nesting, sorts, and compound-query operations. | Interactive diagnosis of planner choices. |
| SQLite EXPLAIN | Virtual-machine opcodes for the prepared statement. | Low-level engine investigation, not routine application logic. |
| PostgreSQL EXPLAIN / EXPLAIN ANALYZE | Plan tree, estimates, costs, and optionally actual timing/rows/buffers. | Plan diagnosis and estimate validation. |
The official documentation explicitly warns that EXPLAIN QUERY PLAN output formatting can change between releases. Test behavior and performance, not exact display strings.
Start from a controlled schema
PRAGMA foreign_keys = ON;DROP TABLE IF EXISTS sales_order;DROP TABLE IF EXISTS customer;CREATE TABLE customer ( customer_id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, region TEXT NOT NULL CHECK (region IN ('north','south','east','west')), joined_at TEXT NOT NULL) STRICT;CREATE TABLE sales_order ( order_id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customer(customer_id), status TEXT NOT NULL CHECK (status IN ('pending','processing','paid','cancelled')), ordered_at TEXT NOT NULL, total_cents INTEGER NOT NULL CHECK (total_cents >= 0), channel TEXT NOT NULL CHECK (channel IN ('web','mobile','partner'))) STRICT;WITH RECURSIVE seq(n) AS ( VALUES (1) UNION ALL SELECT n + 1 FROM seq WHERE n < 1000)INSERT INTO customer (customer_id, email, region, joined_at)SELECT n, printf('customer%04d@example.com', n), CASE n % 4 WHEN 0 THEN 'north' WHEN 1 THEN 'south' WHEN 2 THEN 'east' ELSE 'west' END, date('2023-01-01', printf('+%d days', n % 730))FROM seq;WITH RECURSIVE seq(n) AS ( VALUES (1) UNION ALL SELECT n + 1 FROM seq WHERE n < 20000)INSERT INTO sales_order (order_id, customer_id, status, ordered_at, total_cents, channel)SELECT n, ((n * 37) % 1000) + 1, CASE n % 20 WHEN 0 THEN 'pending' WHEN 1 THEN 'processing' WHEN 2 THEN 'cancelled' ELSE 'paid' END, datetime('2025-01-01', printf('+%d hours', n % 8760)), 1000 + ((n * 7919) % 90000), CASE n % 3 WHEN 0 THEN 'web' WHEN 1 THEN 'mobile' ELSE 'partner' ENDFROM seq;Use the same data volume and distribution for every before/after comparison. Changing data, SQL text, indexes, and statistics simultaneously makes conclusions unreliable.
SCAN versus SEARCH
EXPLAIN QUERY PLANSELECT order_id, total_centsFROM sales_orderWHERE channel = 'mobile';Operation: SCAN sales_orderMeaning: visit a broad portion of the table and test the predicateQuestion: is the match fraction large enough that scanning is reasonable?CREATE INDEX IF NOT EXISTS idx_order_customer_dateON sales_order (customer_id, ordered_at);EXPLAIN QUERY PLANSELECT order_id, ordered_at, total_centsFROM sales_orderWHERE customer_id = 417 AND ordered_at >= '2025-06-01';Operation: SEARCH sales_order USING INDEX idx_order_customer_dateConstraint: customer_id = ? AND ordered_at > ?Meaning: navigate to a narrow key range before fetching rowsCovering and sorting signals
CREATE INDEX idx_order_status_date_totalON sales_order (status, ordered_at, total_cents);EXPLAIN QUERY PLANSELECT ordered_at, total_centsFROM sales_orderWHERE status = 'pending'ORDER BY ordered_at;Operation: SEARCH sales_order USING COVERING INDEX idx_order_status_date_totalTable lookup: avoided for requested columnsOrdering: produced by the same index key orderEXPLAIN QUERY PLANSELECT order_id, customer_id, total_centsFROM sales_orderWHERE status = 'pending'ORDER BY total_cents DESC, customer_id;Plan signal: USE TEMP B-TREE FOR ORDER BYMeaning: qualifying rows must be materialized and sortedDecision: add an index only when this ordering is frequent and valuableJoin plans are nested loops in SQLite
CREATE INDEX IF NOT EXISTS idx_order_customer_dateON sales_order (customer_id, ordered_at);EXPLAIN QUERY PLANSELECT c.customer_id, c.email, o.order_id, o.ordered_atFROM customer AS cJOIN sales_order AS o ON o.customer_id = c.customer_idWHERE c.email = 'customer0417@example.com' AND o.ordered_at >= '2025-06-01';Each indented plan record represents a loop. The earlier record is the outer loop; the later record is repeated for rows produced by its parent. A good plan finds the one customer through the unique email index, then searches that customer’s order range.
Join order and inner access path jointly determine repeated work.
Compound queries and subqueries
EXPLAIN QUERY PLANSELECT customer_id FROM sales_order WHERE status = 'pending'UNIONSELECT customer_id FROM sales_order WHERE status = 'processing';Look for separate branches plus a compound-query step. Because UNION removes duplicates, the plan may need a temporary structure. UNION ALL can avoid that deduplication work when duplicates are acceptable.
Read PostgreSQL estimates as a model
EXPLAIN (COSTS, VERBOSE)SELECT order_id, ordered_at, total_centsFROM sales_orderWHERE customer_id = 417 AND ordered_at >= DATE '2025-06-01'ORDER BY ordered_at DESCLIMIT 20;Node: LimitChild: Index Scan using idx_order_customer_dateCost: startup_cost .. total_costRows: estimated rows emitted by this nodeWidth: estimated average row width in bytesCondition: Index Cond or FilterCost units are internal estimates, not milliseconds. Compare alternatives within the same system and configuration rather than treating a cost value as wall-clock time.
EXPLAIN ANALYZE validates estimates
BEGIN;EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT TEXT)UPDATE sales_orderSET status = 'processing'WHERE order_id = 9001;ROLLBACK;EXPLAIN ANALYZE executes the statement. Wrapping a test modification in a transaction and rolling back can protect data, but triggers, sequences, external functions, and side effects still require care.
| Comparison | Diagnostic meaning |
|---|---|
| estimated rows ≈ actual rows | Cardinality model is credible for this node. |
| estimated rows ≪ actual rows | Underestimation can favor nested loops or undersized memory operations. |
| estimated rows ≫ actual rows | Overestimation can reject useful index paths or overallocate work. |
| high loops on an expensive inner node | The chosen join order repeats substantial work. |
| large temp or disk activity | Sort/hash memory or result size may be the actual bottleneck. |
A disciplined before/after method
Capture
Record SQL text, parameters, schema, indexes, statistics state, data volume, and baseline plan.
Hypothesize
Name one suspected cost: broad scan, repeated lookup, sort, bad estimate, or excessive row width.
Change one thing
Add or alter one index, rewrite one predicate, or refresh statistics.
Re-plan
Verify the expected structural change, not merely a lower-looking number.
Measure
Run representative warm and cold tests, then inspect latency distribution and write impact.
Checkpoint
Read the plan, not the myth
- Why should application tests avoid exact matching of SQLite plan text?
- What is the practical difference between SCAN and SEARCH?
- What does USING COVERING INDEX imply?
- Why can a good index still be accompanied by USE TEMP B-TREE FOR ORDER BY?
- What is the central risk of EXPLAIN ANALYZE on a modifying statement?
Review the answers
SQLite documents the output as unstable presentation. SEARCH indicates constrained index navigation, while SCAN visits a broad source. A covering plan can answer requested columns from the index. Filtering and ordering can require different key order, so a search may still need a sort. EXPLAIN ANALYZE actually executes the statement and can change data or trigger side effects.
Summary and references
- Plans reveal access paths, join order, sorting, and estimated work.
- SQLite plan labels are diagnostic, not a stable machine interface.
- Covering and temporary-sort signals expose important I/O choices.
- PostgreSQL actual rows and loops help locate estimation errors.
- Compare controlled before/after plans and measurements.