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.

Intermediate130–160 minutesPlan literacy + before/after diagnosisLast reviewed: August 2026

Learning outcomes

Turn plans into evidence

01

Distinguish SQLite EXPLAIN from EXPLAIN QUERY PLAN.

02

Recognize SCAN, SEARCH, COVERING INDEX, and temporary B-tree indicators.

03

Read nested-loop order for joins and identify repeated inner work.

04

Interpret PostgreSQL plan nodes, costs, row estimates, and actual measurements.

05

Compare plans before and after a targeted change without depending on unstable text formatting.

Three related tools

ToolWhat it showsPrimary use
SQLite EXPLAIN QUERY PLANHigh-level table/index access, join nesting, sorts, and compound-query operations.Interactive diagnosis of planner choices.
SQLite EXPLAINVirtual-machine opcodes for the prepared statement.Low-level engine investigation, not routine application logic.
PostgreSQL EXPLAIN / EXPLAIN ANALYZEPlan tree, estimates, costs, and optionally actual timing/rows/buffers.Plan diagnosis and estimate validation.
Do not parse SQLite plan text as a stable API

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

sqlite · reset the plan laboratory
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

sqlite · table scan
EXPLAIN QUERY PLANSELECT order_id, total_centsFROM sales_orderWHERE channel = 'mobile';
text · scan interpretation
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?
sqlite · indexed search
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';
text · search interpretation
Operation: SEARCH sales_order USING INDEX idx_order_customer_dateConstraint: customer_id = ? AND ordered_at > ?Meaning: navigate to a narrow key range before fetching rows

Covering and sorting signals

sqlite · covering plan
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;
text · covering interpretation
Operation: SEARCH sales_order USING COVERING INDEX idx_order_status_date_totalTable lookup: avoided for requested columnsOrdering: produced by the same index key order
sqlite · force an unmatched sort order
EXPLAIN QUERY PLANSELECT order_id, customer_id, total_centsFROM sales_orderWHERE status = 'pending'ORDER BY total_cents DESC, customer_id;
text · sort warning
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 valuable

Join plans are nested loops in SQLite

sqlite · join plan
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.

SEARCH customer by unique email
Read customer_id = 417
SEARCH sales_order by customer/date
Return joined rows

Join order and inner access path jointly determine repeated work.

Compound queries and subqueries

sqlite · compound-plan example
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

postgresql · plan without execution
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;
text · abbreviated PostgreSQL plan vocabulary
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 Filter

Cost 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

postgresql · execute and measure safely
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.

ComparisonDiagnostic meaning
estimated rows ≈ actual rowsCardinality model is credible for this node.
estimated rows ≪ actual rowsUnderestimation can favor nested loops or undersized memory operations.
estimated rows ≫ actual rowsOverestimation can reject useful index paths or overallocate work.
high loops on an expensive inner nodeThe chosen join order repeats substantial work.
large temp or disk activitySort/hash memory or result size may be the actual bottleneck.

A disciplined before/after method

1

Capture

Record SQL text, parameters, schema, indexes, statistics state, data volume, and baseline plan.

2

Hypothesize

Name one suspected cost: broad scan, repeated lookup, sort, bad estimate, or excessive row width.

3

Change one thing

Add or alter one index, rewrite one predicate, or refresh statistics.

4

Re-plan

Verify the expected structural change, not merely a lower-looking number.

5

Measure

Run representative warm and cold tests, then inspect latency distribution and write impact.

Checkpoint

Read the plan, not the myth

  1. Why should application tests avoid exact matching of SQLite plan text?
  2. What is the practical difference between SCAN and SEARCH?
  3. What does USING COVERING INDEX imply?
  4. Why can a good index still be accompanied by USE TEMP B-TREE FOR ORDER BY?
  5. 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.

References

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.