EXPLAIN QUERY PLAN Tells You How a Query Will Run
Before tuning a slow query, you need to know what SQLite is actually doing. EXPLAIN QUERY PLAN prints a short summary of the strategy the planner picked — which tables it touches, in what order, and which indexes (if any) it uses. The query itself doesn't run; you just get the plan.
Drop the keywords in front of any statement:
The output looks something like:
QUERY PLAN
`--SEARCH users USING INDEX sqlite_autoindex_users_1 (email=?)
That single line tells you a lot: SQLite is doing a SEARCH (not a scan), on the users table, using the auto-created unique index for email, with email as the lookup key. Exactly what you'd hope for.
SCAN vs SEARCH: The First Thing to Read
Every line in the plan starts with either SCAN or SEARCH. The distinction is the most important signal in the whole output.
SCAN <table>— SQLite reads every row of the table (or every entry of an index). Cost grows with table size.SEARCH <table> USING ...— SQLite jumps directly to matching rows via an index or primary key. Cost grows with the size of the result, not the table.
Here's a side-by-side. One column has an index, the other doesn't:
The first plan reports SEARCH orders USING INDEX idx_orders_customer. The second reports SCAN orders — no index on status, so SQLite reads every row. On a small table that's invisible; on a million-row table it's the difference between milliseconds and seconds.
A SCAN isn't always wrong. For tiny lookup tables, or queries that genuinely return most of the rows, scanning is the right plan. But on a large table with a selective filter, SCAN is your cue to add an index.
Confirming an Index Is Being Used
The phrase to look for is USING INDEX <name> (or USING COVERING INDEX <name> — more on that below). If you created an index hoping the planner would pick it up, this is how you check:
You should see SEARCH events USING INDEX idx_events_user (user_id=?). If instead the plan says SCAN events, something's blocking the planner from using the index — common causes are wrapping the column in a function (WHERE lower(user_id) = ...), comparing different types, or using LIKE '%foo%' with a leading wildcard.
A quick test of that:
That + 0 defeats the index — the plan falls back to SCAN events. Any expression on the indexed column does the same.
Covering Indexes Show Up Differently
When an index contains every column the query needs, SQLite can answer the query from the index alone, without touching the table. The plan reports USING COVERING INDEX:
The plan: SEARCH products USING COVERING INDEX idx_products_sku_price (sku=?). The query asks for price, the index already stores sku and price, so SQLite never reads the underlying table. Covering indexes are the fastest plan you can get for a lookup — worth knowing about when you're picking which columns to index together.
Reading Join Plans
Joins are where plans get interesting. Each line of the plan corresponds to one table in the join, and the order of the lines is the order SQLite visits them. The first table is the outer table; later tables are looked up once per outer row.
A typical plan:
QUERY PLAN
|--SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
`--SEARCH o USING INDEX idx_orders_customer (customer_id=?)
Read it top down: SQLite finds the one customer by primary key, then for that customer looks up matching orders via the index on customer_id. Both lines are SEARCH — no full scans — which is what you want.
If you saw SCAN o on the second line instead, every customer lookup would trigger a full pass over orders. On a large table that's catastrophic. The fix is almost always an index on the join column.
Compound Queries and Subqueries
Plans for UNION, EXCEPT, and subqueries nest. Each branch shows up indented under its parent:
You'll see two child rows under a COMPOUND QUERY heading, one per branch. Subqueries and CTEs work similarly — each gets its own indented plan node, and you read each one with the same SCAN vs SEARCH lens.
The subquery becomes a separate plan node ("LIST SUBQUERY" or similar), with its own access strategy. Apply the same checks at every level.
EXPLAIN vs EXPLAIN QUERY PLAN
These are two different things, and people mix them up.
EXPLAIN (no QUERY PLAN) dumps the bytecode SQLite's virtual machine will execute — dozens of low-level opcodes like OpenRead, SeekRowid, Column, ResultRow. Useful if you're debugging the engine itself. Almost never useful for tuning.
EXPLAIN QUERY PLAN is the human-readable summary you actually want. When in doubt, always reach for EXPLAIN QUERY PLAN.
A Workflow for Slow Queries
When a query is slow, the loop looks like this:
- Run
EXPLAIN QUERY PLANon it. - For each table line, ask: is this
SCANorSEARCH? On a big table,SCANis the suspect. - If a
SCANis filtering on some column, consider an index on that column. - For joins, confirm the inner-loop tables use
SEARCH USING INDEXon the join column. - Re-run
EXPLAIN QUERY PLANafter adding the index. The plan should change. If it didn't, the planner decided your index wasn't worth using — usually because the table is small or the filter isn't selective enough.
A worked example of step 5:
The plan changed from SCAN to SEARCH. That's the signal the index is doing its job. (On a fresh, near-empty table the planner may still scan because there isn't enough data to bother with the index — populate the table or run ANALYZE and the choice often flips.)
What the Plan Won't Tell You
EXPLAIN QUERY PLAN describes strategy, not cost. It won't tell you the query took 800 ms or returned 50,000 rows. For that you want timing (.timer on in the CLI) and row counts. The plan and the timing are complementary — the plan tells you why a query is slow, the timer tells you whether it is.
Two more limitations worth knowing:
- The plan can change as data grows. A query that scanned a 100-row table happily will need an index when the table hits a million rows. Re-check plans on production-sized data, not your dev fixtures.
- The planner uses statistics gathered by
ANALYZE. Without them, it falls back to defaults that aren't always great. Stale or missing stats are a common reason for surprising plans.
Next: ANALYZE and VACUUM
The query planner makes its decisions based on statistics about your tables and indexes. If those stats are missing or outdated, even a perfectly-indexed schema can produce a bad plan. ANALYZE is how you keep them fresh — and VACUUM is the companion command for reclaiming space and defragmenting the database file. That's next.
Frequently Asked Questions
What does EXPLAIN QUERY PLAN do in SQLite?
It asks SQLite to describe how it would execute a query without actually running it. The output shows which tables are scanned, which indexes are used, and how joins are ordered. Prefix any SELECT, INSERT, UPDATE, or DELETE with EXPLAIN QUERY PLAN to see the plan.
What's the difference between SCAN and SEARCH in the output?
SCAN means SQLite reads every row of a table or index — fine for small tables, expensive for large ones. SEARCH means it jumps directly to matching rows using an index or primary key. On a large table, you almost always want SEARCH for the filtering columns.
How do I check if my query is using an index?
Run EXPLAIN QUERY PLAN on the query and look for USING INDEX <name> or USING COVERING INDEX <name> in the output. If you only see SCAN <table> with no index mentioned, the query is doing a full table scan and an index would likely help.
What's the difference between EXPLAIN and EXPLAIN QUERY PLAN?
EXPLAIN shows the low-level virtual machine bytecode SQLite generates — useful for engine internals, rarely useful for tuning queries. EXPLAIN QUERY PLAN shows a human-readable summary of table access and index usage. For performance work, you almost always want EXPLAIN QUERY PLAN.