Indexes, ANALYZE and EXPLAIN QUERY PLAN
How the planner chooses, covering and partial and expression indexes, reading a query plan, and keeping statistics fresh.
Reading the plan
explain query plan
select b.title from book b join author a on a.id = b.author_id where a.name = 'Le Guin';
-- SEARCH a USING INDEX author_name_idx (name=?)
-- SEARCH b USING INDEX book_author_idx (author_id=?)
explain query plan
select * from book where title like '%dispossessed%';
-- SCAN book <- no index can help a leading wildcard
explain query plan
select count(*) from book where status = 'published';
-- SCAN book USING COVERING INDEX book_status_idx
-- COVERING INDEX means every needed column is in the index: no table lookup| Plan output | Meaning |
|---|---|
SCAN table | Full table scan |
SEARCH table USING INDEX | Index lookup, the good outcome |
SEARCH ... USING COVERING INDEX | Index-only: no table access |
USE TEMP B-TREE FOR ORDER BY | Sorting in memory or on disk; an index on the sort columns removes it |
SCALAR SUBQUERY | Runs per row unless cached by the planner |
MULTI-INDEX OR | Two indexes combined; often better as a single composite index |
- Run the plan with
explain query plan: it needs no data and is fast enough to use during development. pragma vdbe_opcode_traceandexplainshow the full bytecode, which is useful only for genuinely obscure cases.- The rightmost table in the plan is scanned first in SQLite's output ordering; read it as the access order, inner to outer.
💡
analyze writes statistics into sqlite_stat1, and the planner uses them to choose between two usable indexes. Without statistics it guesses, and the guess gets worse as the data grows. Run it after a bulk import, not on every write.Index shapes
-- composite: order matters, leftmost prefix rule applies
create index book_author_published on book (author_id, published desc);
-- covering: includes the columns a hot query reads
create index book_author_cover on book (author_id, published desc, title);
-- partial: index only the pending rows in a mostly-processed queue
create index job_pending on job (run_after)
where status = 'pending';
-- expression: index a computed value
create index book_slug on book (lower(replace(title, ' ', '-')));
create index book_year on book (cast(strftime('%Y', published) as integer));
-- unique index instead of a unique constraint, when you want a partial unique
create unique index one_active_sub on subscription (account_id)
where ended_at is null;
-- see what exists
select name, tbl_name, sql from sqlite_master where type = 'index' and tbl_name = 'book';- An expression index is only used if the query contains the identical expression.
lower(title)matcheslower(title);title collate nocasedoes not. - A partial index is only used when the query's WHERE clause implies the index's WHERE clause.
where status = 'pending'matches;where status != 'done'does not. - Covering indexes trade disk and write cost for read speed. Check with
EXPLAIN QUERY PLANthat the planner actually uses the covering form. - Every index must be updated on insert, update and delete. Ten indexes on a write-heavy table can triple the write cost.
-- statistics: run after a large change, not in a hot write path
analyze;
analyze book; -- or a single table, which is cheaper
-- with statistics, the planner can prefer the better of two indexes
-- without them, add an explicit hint by rewriting the query to be more selectivePragmas that affect query speed
pragma cache_size = -64000; -- negative means KiB: 64 MB page cache
pragma temp_store = memory; -- temp tables and sorts in RAM
pragma mmap_size = 268435456; -- 256 MB of memory-mapped I/O
pragma page_size; -- must be set before the first table is created
pragma cache_spill;
-- prefer an index for a small table when the planner guesses wrong
analyze sqlite_schema; -- or sqlite_master on older versions
-- and consider: ANALYZE sqlite_schema; update sqlite_stat1 set stat = '...'cache_sizeis the single most effective read tunable: it keeps hot pages in memory instead of reading them from the file.mmap_sizehelps on 64-bit systems with a database that fits in address space, and the value must be set per connection.temp_store = memorymoves temporary B-trees for sorts and grouping into memory; watch memory use on a large sort.page_sizeonly takes effect when the database is created - changing it later requiresVACUUM.- Do not tune by guessing. Measure with a realistic dataset and the same statements the application issues.
FAQ
Why is my index not used?
Check for an expression mismatch, a leading wildcard in LIKE, a function applied to the indexed column, or an affinity mismatch in the comparison. Then run
analyze in case the planner has stale statistics.How many indexes should a table have?
As few as the read patterns justify. Each index costs write time and disk. Start with the primary key, the foreign keys, and one index for each proven hot query, and re-check after a bulk load with
analyze.Related
Querying with SQL: joins, grouping, upserts and CTEs Testing, tooling and benchmarks for SQLite apps
Last refreshed 2026-09-18.