Chapter 10 · Indexes and the SQLite Query Planner
EXPLAIN QUERY PLAN, EXPLAIN, SCAN/SEARCH, and Planner Diagnostics
Make EXPLAIN QUERY PLAN the default diagnostic workflow: distinguish SCAN from SEARCH, recognize covering and automatic indexes and temporary B-trees, inspect join loops, and use shell planner tools without depending on unstable text output.
Learning outcomes
Index design becomes disciplined when every change starts and
ends with planner evidence. SQLite exposes two related SQL
diagnostics: EXPLAIN QUERY PLAN gives a high-level
access-path tree, while full EXPLAIN exposes the
lower-level virtual-machine program. The first belongs in
everyday tuning; the second is an advanced microscope. The
sqlite3 shell adds conveniences such as .eqp and
experimental .expert.
Read SCAN, SEARCH, named-index, and COVERING INDEX details without depending on exact output formatting.
Recognize temporary B-trees used for ORDER BY/GROUP BY/DISTINCT and identify when an index removes that work.
Interpret join-loop order from the plan tree rather than SQL text order.
Distinguish persistent schema indexes from automatic query-time indexes.
Use .eqp and experimental .expert as CLI investigation tools, not production APIs.
Use full EXPLAIN bytecode only when high-level plan evidence is insufficient.
EXPLAIN QUERY PLAN is the everyday tool
Prefix a SELECT—or another statement that reads tables—with
EXPLAIN QUERY PLAN. SQLite returns a tree
describing its selected strategy. The official documentation
explicitly warns that this output is for interactive debugging
and its format may change across releases. Therefore, tests
should not parse strings like
SEARCH maintenance_note USING INDEX... as a stable
application API.
EXPLAIN QUERY PLANSELECT note_id, summaryFROM maintenance_noteWHERE device_id=42;
| Plan word | Practical reading |
|---|---|
SCAN t |
SQLite visits all rows/entries in a table or index scan; it is not narrowing to a small key subset. |
SEARCH t ... |
SQLite uses rowid/index constraints to visit a subset. |
USING INDEX name |
A persistent named index participates. |
USING COVERING INDEX name |
The query can get needed table values from that index without table lookups. |
USING AUTOMATIC ... INDEX |
SQLite built a transient query-time index for this statement. |
USE TEMP B-TREE FOR ORDER BY |
A temporary sorting structure is required for that operation. |
Diagnose a filter: SCAN → SEARCH
Start with no maintenance-note indexes. The device filter scans. Add the measured candidate and inspect again.
DROP INDEX IF EXISTS idx_note_device;DROP INDEX IF EXISTS idx_note_device_time;EXPLAIN QUERY PLANSELECT * FROM maintenance_note WHERE device_id=42;CREATE INDEX idx_note_device ON maintenance_note(device_id);EXPLAIN QUERY PLANSELECT * FROM maintenance_note WHERE device_id=42;
The important conclusion is not that SEARCH is always superior. It is that the planner has switched from visiting the whole table to a key-restricted access path for this selective predicate.
Diagnose a sort: temp B-tree → index order
A query can use an index to find rows yet still allocate a temporary B-tree for ORDER BY if the index does not produce the requested order.
-- Only idx_note_device exists here.EXPLAIN QUERY PLANSELECT occurred_at, summaryFROM maintenance_noteWHERE device_id=42ORDER BY occurred_at DESC;-- Likely SEARCH plus USE TEMP B-TREE FOR ORDER BY.CREATE INDEX idx_note_device_timeON maintenance_note(device_id, occurred_at DESC);EXPLAIN QUERY PLANSELECT occurred_at, summaryFROM maintenance_noteWHERE device_id=42ORDER BY occurred_at DESC;-- The composite index can normally remove the separate ORDER BY sort.
Join plans are nested loops
SQLite implements joins as nested scans/searches. EQP emits one SCAN/SEARCH node for each loop; node order reveals which loop is outer and which is inner. This may differ from the table order you wrote in SQL because the planner is free to reorder compatible joins.
EXPLAIN QUERY PLANSELECT d.device_code, n.occurred_at, n.summaryFROM device AS dJOIN maintenance_note AS n ON n.device_id=d.device_idWHERE d.site_id=7 AND n.status='open';
If device.site_id lacks an index, the outer side
may scan devices. If maintenance_note.device_id has
a suitable index, the inner loop may SEARCH notes for each
matching device. The right improvement depends on measured
selectivity and the complete workload.
Automatic indexes are query-time structures, not sqlite_autoindex constraints
When a statement would otherwise repeat expensive lookups and no
persistent index exists, SQLite may build an
automatic query-time index for that one
statement. It is temporary and distinct from names like
sqlite_autoindex_device_1, which are persistent
internal indexes created by constraints.
DROP TABLE IF EXISTS left_side;DROP TABLE IF EXISTS right_side;CREATE TABLE left_side(k INTEGER, payload TEXT);CREATE TABLE right_side(k INTEGER, payload TEXT);WITH RECURSIVE seq(x) AS ( VALUES(1) UNION ALL SELECT x+1 FROM seq WHERE x<5000)INSERT INTO left_side SELECT x%500, printf('L%05d',x) FROM seq;WITH RECURSIVE seq(x) AS ( VALUES(1) UNION ALL SELECT x+1 FROM seq WHERE x<5000)INSERT INTO right_side SELECT x%500, printf('R%05d',x) FROM seq;EXPLAIN QUERY PLANSELECT count(*)FROM left_side AS lJOIN right_side AS r ON r.k=l.kWHERE l.k=42;
On normal builds with automatic indexing enabled, you may see an
AUTOMATIC index on one loop. Treat repeated
automatic-index warnings/plans as evidence that a persistent
schema index may deserve evaluation—not as a command to add
every suggested index.
Temporary B-trees also appear for GROUP BY and DISTINCT
EXPLAIN QUERY PLANSELECT technician, COUNT(*)FROM maintenance_noteGROUP BY technician;EXPLAIN QUERY PLANSELECT DISTINCT technicianFROM maintenance_note;
If no suitable ordering/index exists, EQP may report a temp
B-tree for GROUP BY or DISTINCT. Creating
INDEX ... (technician) can change that plan, but
the write/storage tradeoff still needs justification from
workload frequency and measurement.
The shell makes EQP interactive
The sqlite3 CLI can automatically display a plan before each statement.
.eqp onSELECT * FROM maintenance_note WHERE device_id=42;.eqp off.explain auto-- Controls shell formatting for EXPLAIN output.
.eqp and .explain are shell commands,
not SQL and not SQLite library APIs. They affect how the CLI
investigates statements; they do not change query semantics.
.expert can suggest indexes—but it is experimental
Current official sqlite3 builds document .expert as
an experimental index-recommendation feature. It analyzes a
query and can propose candidate CREATE INDEX statements,
optionally sampling data distribution. Its interface can change
or disappear, and a recommendation still requires human review
for write cost, storage, redundancy, and the rest of the
workload.
.expertSELECT *FROM maintenance_noteWHERE device_id=? AND occurred_at>=?ORDER BY occurred_at;-- Current CLI also documents options such as:.expert --sample 100SELECT * FROM maintenance_note WHERE status=?;
Availability depends on the shell build you are actually
running. Check .help expert. Never make production
correctness depend on this CLI feature.
Full EXPLAIN: inspect SQLite VM bytecode only when needed
SQLite compiles statements into instructions for its virtual
database engine. Prefixing with EXPLAIN returns
that instruction sequence—opcodes such as opening cursors,
seeking indexes, comparing values, and producing result rows.
This is lower-level than most application tuning needs and even
less suitable for machine parsing across releases.
EXPLAINSELECT note_id, summaryFROM maintenance_noteWHERE device_id=42ORDER BY occurred_at DESCLIMIT 5;
Use EQP to answer “which access path?” first. Escalate to full EXPLAIN when investigating detailed execution behavior, teaching internals, or diagnosing a planner case that high-level output does not explain.
Evidence-driven tuning sequence
- Capture the exact SQL and representative bound-value shapes.
- Record row counts and realistic data distribution.
- Run EQP and identify SCAN/SEARCH, temp B-trees, and join order.
- Inspect existing indexes and constraints before adding anything.
- Create one candidate index in a disposable/staging copy.
- Run EQP again and verify the intended plan change.
- Measure query latency/I/O at realistic scale if performance matters.
- Measure write/storage effects and check for redundant indexes.
- Use statistics/PRAGMA optimize appropriately before judging final plans.
- Keep planner output as diagnostic evidence, not a brittle application contract.
Cleanup and checkpoint
DROP TABLE IF EXISTS left_side;DROP TABLE IF EXISTS right_side;DROP INDEX IF EXISTS idx_note_device;DROP INDEX IF EXISTS idx_note_device_time;DROP INDEX IF EXISTS idx_note_technician;PRAGMA index_list('maintenance_note');
Planner-diagnostics checkpoint
Interpret plans at the right abstraction level.
- What practical difference separates SCAN from SEARCH?
- What does USING COVERING INDEX tell you?
- What does USE TEMP B-TREE FOR ORDER BY reveal?
- Why should applications not parse EQP text?
- How is an automatic query-time index different from sqlite_autoindex_*?
- What role should .expert play in index design?
- When is full EXPLAIN appropriate?
Review the answers
SEARCH means a constrained subset lookup; SCAN visits the full table/index range.
A covering index supplies all needed values.
A temp B-tree means SQLite needs an auxiliary sort/group/distinct structure.
EQP formatting is explicitly unstable across releases.
Automatic query-time indexes are transient per-statement structures, whereas sqlite_autoindex names usually implement persistent constraints.
.expert is an experimental advisor whose suggestions require workload review.
Full EXPLAIN is an advanced virtual-machine inspection tool after high-level EQP.
Production judgment and bridge
A healthy performance workflow leaves an evidence trail, not a
collection of folklore rules. Lesson 5 adds the final missing
input to the cost model: statistics. You will see a real plan
change after ANALYZE, inspect sqlite_stat1, learn
why sqlite_stat4 is optional, and adopt the current
PRAGMA optimize lifecycle guidance.