Query optimizers / personal lessonsLesson 5 of 5
SQLite · Lesson 5

Investigating slow queries

Use the plan to explain where a query may be doing unnecessary work. Test one change and measure whether it helps.

This combines statistics, nested-loop access, and index design. EXPLAIN QUERY PLAN, or EQP, shows the strategy SQLite chose. It does not report how long each part took.

Record what is slow and reproduce it safely

Save the exact query and parameter values, including the types the application binds. Record the expected result, database size, schema, and indexes. Record whether the delay affects every value or just a large customer, and whether it appears only under concurrent writes.

SELECT sqlite_version();
PRAGMA compile_options;
PRAGMA index_list('orders');
EXPLAIN QUERY PLAN
SELECT id, created_at, total FROM orders
WHERE customer_id=42
ORDER BY created_at DESC LIMIT 20;

Use a disposable copy or the companion experiment. Use SQLite's backup facilities, such as the shell's .backup command, to get a consistent copy. Copying a live database file directly can miss data in its write-ahead log.

Define the measured operation before selecting a fix

A slow screen is an observation about a user-visible operation. It is not yet evidence that one SELECT has a poor plan. The operation may include waiting for a connection, preparing a statement, stepping through rows, creating application objects, and rendering the result. Define the timing boundary so that a later claim of improvement refers to the same operation.

For example, suppose a constructed request takes one hundred milliseconds. Ten milliseconds occur while executing and reading the database result, while ninety occur elsewhere. Even eliminating all ten database milliseconds would leave ninety milliseconds. This simple bound prevents attributing a potential tenfold improvement in the query to a tenfold improvement in the whole request. The diagram's stages illustrate this distinction; they are not measurements of your application.

Time statement execution through the required result consumption. Preparing a statement establishes an executable representation but does not generally perform the full retrieval. Reading just the first row also does not measure a query whose caller consumes ten thousand rows. Conversely, a caller that legitimately stops early has a different operation from one that drains every row. Match the measurement to the actual requirement.

Record whether the delay affects all requests or a subset. A large customer, an absent lookup value, a concurrent writer, or a first run after startup can expose different mechanisms. Preserve those conditions in the test case rather than replacing them with whichever example is easiest to run. Reproducibility requires the relevant conditions, not merely a copy of the SQL text.

A safe copy matters for correctness as well as safety. A live database can have committed data in a write-ahead log that a naive file copy omits. Use a supported backup method and check that the copied database contains the expected records. Then record its schema, statistics, and settings. A perfectly repeatable test against the wrong snapshot can still produce an explanation that does not apply to the observed delay.

Time the real statement, not EXPLAIN

-- In the SQLite shell, not inside application SQL:
.timer on
SELECT id, created_at, total FROM orders
WHERE customer_id=42
ORDER BY created_at DESC LIMIT 20;
.timer off

The shell reports elapsed and CPU timings for the statement. Formatting and output can affect shell measurements; keep them consistent. In an application, include stepping through the complete result in your timing rather than timing statement preparation alone.

The shell's .stats on can add statement counters, including VM steps and full-scan steps in supported versions. These counters do not measure elapsed time or count distinct disk reads. Optional .scanstats on requires scan-status support in the build; it is unavailable in some otherwise normal installations.

Investigate one customer's newest orders

Start with indexes.sql in a fresh database. It shows the following changes in access path; exact text and choices may differ across builds.

-- Date-only index:
SCAN orders USING INDEX orders_date_idx

-- After adding (customer_id, created_at):
SEARCH orders USING INDEX orders_customer_date_idx (customer_id=?)

-- After adding (customer_id, created_at, total):
SEARCH orders USING COVERING INDEX orders_cover_idx (customer_id=?)

Explanation

The date-only index supplies order but mixes customers, so it may pass many irrelevant entries to find 20 matches.

Prediction

A customer/date index narrows the search to one customer while retaining date order. No separate temporary sorting B-tree should be needed.

A SEARCH bounded by customer_id confirms that SQLite now looks up one customer rather than scanning across customers. COVERING means it can get the requested values from the index. Neither change tells you how much faster the query runs.

To measure the change, run the original query repeatedly before creating the composite index, then repeat under comparable conditions after creating it. The companion script prints plans, not timings. Running its steps in sequence is not a fair benchmark because later queries may benefit from data already in memory.

Verify that the same intended rows are returned. If timestamps can tie, add the same deterministic tie-breaker to both comparisons. Test customers with few orders and customers with many orders. Check index size and the extra work on writes before deployment. The old date index may still help queries that search dates across all customers.

Test a specific explanation

Make the predicted change falsifiable

A useful hypothesis names the unnecessary work and the evidence that would change your mind. For the newest-orders query, say that a date-only route considers many other customers' entries before finding enough matches. Predict that a customer/date index will restrict the search to one customer's group while preserving the requested order. This is more specific than saying that the query needs an index.

Separate three checks. First, did SQLite use the proposed route? A new index in the schema does not prove that it was chosen. Second, did the query return the same intended rows? A smaller incorrect result can be faster. Third, did repeated measurements improve under the relevant conditions? A plan change is evidence about the route, not a direct measurement of benefit.

Suppose the plan changes as predicted but timings remain similar. Several explanations remain possible. The old route may already find enough matching rows quickly. The whole dataset may be small and cached. Application work may dominate the measured boundary. Or normal measurement variation may hide a small difference. Do not invent a cause merely to preserve the original hypothesis; design another comparison that distinguishes these possibilities.

Suppose instead that timings improve but the plan text stays the same. A warmer cache, different contention, or changed data can affect runtime without changing the high-level access description. Statistics and implementation details can also matter beyond the text you inspected. The observation supports an improvement in those measured runs, but it does not by itself establish that the intended index change caused it.

Write down what you expect before changing the test copy. This prevents choosing an explanation after seeing the result and then treating it as a prediction. Preserve a baseline that you can reproduce. If several indexes, the data distribution, and the query all change together, you may find a faster configuration, but you have not isolated which change produced the benefit or whether all of them are necessary.

Control the comparison

Use the same data, parameters, result requirements, SQLite build, and settings. Warm up both cases and repeat measurements. Alternate the old and new configurations when practical, so the new version does not always benefit from a warm cache. If you use separate database copies, check their cache states and statistics too.

Which comparison tests the index?

You add an index and want to know whether it helps a frequently used query. Choose a measurement procedure.

Choose a procedure to see what it can tell you.

Read all explanations

The second query benefits from data loaded by the first. A lower elapsed time alone cannot distinguish the index effect from the cache effect.

Both versions run after warm-up with the same parameters, so this comparison better isolates the index effect. Alternate their order and repeat the measurements. Check the plans and result rows as well as elapsed time.

Different customer values can produce different row counts and plans. Use the same representative parameter values for both versions before concluding that the index caused the difference.

Report the median and range, not the fastest run. A difference smaller than the normal variation between runs does not show a reliable improvement. Do not clear production caches just to create a cold test.

Use repeated measurements to separate effects from variation

Consider two invented sets of elapsed times in milliseconds. The baseline is 8, 9, 10, 11, and 12. The candidate is 7, 8, 9, 10, and 11. Their medians are ten and nine, but the ranges overlap strongly. These few observations suggest a possible small improvement; they do not establish a precise ten-percent benefit across the workload. More repetitions under controlled conditions would help distinguish the effect from variation.

Now compare that baseline with candidate times of 2, 3, 3, 4, and 4. The difference is larger than the variation within either small set. That is stronger evidence for the tested condition, provided both configurations returned equivalent results and other conditions were controlled. It still says nothing directly about another customer, a larger database, or performance during heavy writes.

Alternating configurations or using a balanced test order reduces one simple bias: always measuring the candidate after the baseline has warmed the needed data. Repetition alone does not remove that bias if every pair uses the same favorable order. Likewise, two separate files can have different cache histories. Record the setup rather than claiming "same machine" is enough to make the runs comparable.

Return to the invented baseline measurements and predict what removing the slowest observation would do. Without twelve, the remaining times are eight, nine, ten, and eleven. Their median is nine and a half rather than ten, and their range is eight to eleven rather than eight to twelve. Removing an observation changes the summary even though no query improved. An unusually slow run can reveal contention or another workload condition, so do not discard it merely because it makes the candidate look less attractive. Investigate its cause or report the inclusion rule explicitly.

Keep result handling consistent. Printing thousands of rows to a terminal can dominate a shell measurement, while suppressing output changes that boundary. In application tests, object construction and serialization can matter too. Use the same handling for both configurations and explain whether the reported time includes it. A useful report gives the query, parameters, run conditions, number of repetitions, and summary of variation, not just a ratio between two selected observations.

When the query plan is not the main delay

A fast query inside a slow request

This invented request takes 1,000 ms. Its stages do not overlap, so their durations add up to the total.

Connection-pool wait
750 ms
Database execution
20 ms
Result fetching
80 ms
Application processing
150 ms
If database execution became instant, how much time would remain?

980 ms. Even removing the entire 20 ms would improve this request by only 2%. Most of this example's delay is waiting for a connection. Check why the application waits so long for one before changing the SELECT's access path.

These example durations describe an application request, not EXPLAIN output. Waiting for a database lock would count as database execution time. The pool wait happens before the application gets a connection to run the statement.

SQLite runs inside the application process and does not need a network connection to a database server. Your application can still spend time waiting for a pooled connection, contending with another writer, copying many result values into application objects, serializing results, or rendering them.

If delays occur during writes, check how long transactions stay open and how the application waits or retries when SQLite reports a busy database. Measure before changing journal mode or timeouts. If a query behaves differently in the application and the shell, compare their SQLite versions, compile options, bound value types, schema, statistics, and transaction conditions.

Check the wider consequences

Carry the evidence into a deployment decision

A successful test supports a particular change under particular conditions. Before deployment, repeat the important checks for common and rare parameter values, empty results, large groups, and tied ordering values. Compare full rows and multiplicities where order is not required, and compare the defined sequence where it is. Two results with the same row count can contain different customers or omit different duplicates.

For an outer-join report, even the choice of count can affect correctness. COUNT(*) counts an unmatched customer's preserved row, while COUNT of a non-NULL order identifier counts only rows with an actual order identifier. A customer with no orders can therefore contribute one to the first count and zero to the second. Include such a customer when testing a rewrite of an order-count report. Also check a customer with two orders, because a test that contains only zero or one match does not expose accidental removal of duplicates. This is a result-definition question before it is a performance question.

Include the cost of maintaining the candidate index. A read-heavy screen can benefit while a bulk import slows down. Measure relevant insert and update paths rather than assuming that a small read improvement outweighs every write cost. Also check the space required during index construction and afterward. The experiment's tiny database does not establish the resources a production build will need.

Plan how to observe the change and recover if it harms the workload. Preserve the earlier schema definition and the evidence that motivated the change. Removing a newly added ordinary index can restore the old set of access options, but do not casually remove indexes that enforce uniqueness or serve other queries. Schema changes and database backups require an application-appropriate deployment procedure.

If the application and shell disagree, compare their environments before concluding that SQLite behaves unpredictably. They may use different library versions, compile options, parameter bindings, connection settings, or data snapshots. A statement executed inside a long transaction also has different surrounding conditions from an isolated shell request. Record these differences as candidate explanations and test them separately.

A final investigation note should distinguish observations from conclusions. State the old and new access paths, the result-equivalence check, the timing summaries, and any write or storage tradeoff you measured. Name what remains unknown, such as performance during concurrent writes. That record lets another person reproduce the claim and judge whether the change fits their workload without mistaking a teaching diagram or a single fast run for production evidence.

Independent cases

Case A: a repeated full scan

|--SCAN c
`--SCAN o

A query joins customers to orders on customer_id and is slow for a large customer selection. What access-path change would you investigate?

Hint

Inspect the inner access, not just the final result size.

Worked answer

A lookup skips rows that a full scan must check

● Input▧ Work✓ Match× Rejected◆ Result
Scan orders for customer 2
Customer 2
Order 1×
Order 2✓
Order 3×
Order 4×
Order 5✓
Order 6×
Order 7×
Order 8×

8 candidates checked
Nonmatches are read and rejected.

Index lookup for customer 2
Customer 2
Order 1—
Order 2✓
Order 3—
Order 4—
Order 5✓
Order 6—
Order 7—
Order 8—

2 matching entries
Other rows lie outside this lookup.

Eight invented order rows, with two matches for customer 2. The search view leaves out the steps through the index tree. These counts explain the proposed lookup, not measurements from the exercise.

The plan suggests that orders is repeatedly scanned for the outer customers. Count the qualifying customers and test an index on the order join key. Predict a SEARCH replacing the inner SCAN. If a later plan uses an automatic index for the lookup, include its build cost in the comparison. EQP alone does not establish the exact number of repetitions.

Case B: a temporary sort

|--SCAN events
`--USE TEMP B-TREE FOR ORDER BY

The application displays only 50 rows. Does this plan prove that you need more sort memory?

Hint

Does this line distinguish memory use from a disk spill?

Worked answer

The first rows found may not be the newest

● Input▧ Work✓ Match× Rejected◆ Result
Unordered input
Jan 2
Jan 6
Jan 1
Jan 5
Jan 3
Jan 4
▧ Check all six dates to find the newest two.
◆ Jan 6   ◆ Jan 5
Index already in newest-first order
Jan 6
Jan 5
Jan 4
Jan 3
Jan 2
Jan 1
Stop after the first two qualifying rows.
◆ Jan 6   ◆ Jan 5
This smaller example returns two rows rather than 50. Bar height shows the day of the month, not execution cost. The unordered route considers every candidate; the matching index can stop early. A temporary B-tree line alone does not prove a disk spill.

No. It shows a sorting structure, not proof of disk I/O. If only the newest 50 rows are needed, test an index that supplies the requested order. Add a SQL LIMIT only if the application does not need the remaining rows. Predict the disappearance of the temporary-sort entry, then measure the real query.

Case C: an apparent speedup

A cold query for customer A takes 90 ms. After creating an index, a warm query for customer B takes 2 ms. Does that establish a 45-times speedup from the index?

Hint

More than one factor changed.

Worked answer

The index, cache state, and customer all changed

● Input▧ Work✓ Match× Rejected◆ Result
90 msIndex: old ↔Cache: cold ↔Customer: A ↔
2 msIndex: new ↔Cache: warm ↔Customer: B ↔
Controlled rerun↔ Index changes= Same customer= Comparable cache state
90 ÷ 2 = 45 is correct arithmetic, but the index is not the only possible cause of the speedup. Use the same customer and comparable cache conditions, then repeat both configurations.

The index, cache state, and customer all changed. Any of them could explain the shorter time. Run both index configurations with the same representative customers and comparable cache conditions. Compare plans, results, and repeated elapsed times before attributing the change to the index.

Final case: a late date condition

SELECT c.id, o.id FROM customers c
JOIN orders o ON o.customer_id=c.id
WHERE c.segment='trial' AND o.created_at>='2025-01-01';

-- Example plan:
|--SCAN c
`--SEARCH o USING INDEX orders_customer_idx (customer_id=?)

Propose one index change, predict the plan difference, and describe a fair test.

Hint

Which conditions appear in the search bounds?

Worked answer

Use a date bound to skip older entries

● Input▧ Work✓ Match× Rejected◆ Result
Customer only, then date check
Customer 42 · Jan 6 or later
Jan 1×
Jan 2×
Jan 3×
Jan 4×
Jan 5×
Jan 6✓
Jan 7✓
Jan 8✓

8 candidates checked
Nonmatches are read and rejected.

Customer + date range
Customer 42 · Jan 6 or later
Jan 1—
Jan 2—
Jan 3—
Jan 4—
Jan 5—
Jan 6✓
Jan 7✓
Jan 8✓

3 matching entries
Other rows lie outside this lookup.

Customer 42 has eight entries in this small example. The query needs Jan 6 onward. The customer-only lookup reads eight entries and rejects five. The customer/date lookup starts at Jan 6. These counts explain the lookup, not actual EQP output.

The inner search uses only customer_id to find candidates. SQLite must then check their dates. Test a customer/date composite index on a copy. Predict a search detail containing both customer and date, with the same result rows.

Use counts to understand the data and repeated timings for the actual query. Optional statement counters can add evidence where available. This EQP output does not report actual row counts, filter rejections, or loop timings.

Investigation checklist

  1. Record the query, parameter values and types, expected result, and conditions where it is slow.
  2. Capture EQP and identify scan/search bounds, loop order, coverage, and sorting.
  3. Measure separately. Know which counters your build can actually supply.
  4. Explain the suspected cause and predict how a change would affect the plan.
  5. Change one factor and compare equivalent results under comparable conditions.
  6. Check representative values, write costs, storage, and concurrency before deployment.

Repeatable experiments: basic scans, statistics, joins, and index order. Run each script with a new disposable database file. The scripts leave the data in place for you to inspect or delete.