Chapter 07 · 12 minutes
Guard it in CI
A fast query is one careless migration away from slow again. Check query plans on every pull request, so a dropped index fails the build instead of the pager.
Goal
Run the plan check, see it fail without indexes and pass with them, and wire it into CI the way this repository does.
Plans are stable, timings are not
You could fail CI when a query takes over 5 milliseconds, but CI machines are noisy and the test would be flaky. The plan is deterministic: given the schema, the indexes and the data's statistics, SQLite picks the same steps every time. So the check asserts on the plan.
Every case in lab/shop/bench.py is guarded unless it says otherwise. check fails if a guarded query's plan contains a full table scan or a temp sort:
uv run python -m shop check
With your lab/indexes.sql from the earlier chapters, every line should say ok. To see it fail, comment out one line in lab/indexes.sql with --, run check again, and read which query it names:
FAIL recent_orders SCAN orders | USE TEMP B-TREE FOR ORDER BY
check exits with 1 when anything fails, which is all CI needs to block a merge.
The reference answer
lab/solutions/indexes.sql holds one set of indexes that passes. Compare it with yours: there is more than one right answer, and the check does not care which index you chose, only that no guarded query reads the whole table or sorts afterwards.
In CI
The workflow in .github/workflows/lab.yml runs lint and tests, then:
- name: Query plans (no full scans, no temp sorts)
run: uv run python -m shop --indexes solutions/indexes.sql check
In your own project, point it at your real migrations instead of a solutions file. The test pattern carries over directly: build the schema, run EXPLAIN on the queries your pages send, and fail on the steps you never want to see. Postgres can do the same with EXPLAIN (FORMAT JSON), as long as the test database has realistic statistics.
Check it works
checkexits with 0 and prints0 of 5 guarded queries need an indexwith your indexes.uv run pytest -k checkpasses: it proves the check fails without indexes and passes with the solution.
Exercise
Look back at the ranking you wrote in chapter 01. Which query was harder to fix than you expected, and which was easier? Then add a new guarded case to CASES in lab/shop/bench.py: "a customer's refunded orders this year". Write the query, run check, watch it fail, and fix it with the smallest index change you can.
More to try
checkuses a database of 200,000 orders. Run it on 2,000 by passing a smallerorderstobuild(). Do the plans change? A tiny test database can make the planner choose a scan just because the table is small.- Add a guard for statement counts too, so an N+1 regression fails CI the same way a missing index does.