The missing-index check on SQLite#

The index check (--queryguard-index-check or queryguard_index_check = true) works on SQLite as well as on PostgreSQL 16 and newer. One switch covers both; the dialect of the connection decides how a statement is checked. The PostgreSQL check is described in docs/design-0.2.md and docs/design-0.3.md.

What it does#

For each distinct statement shape in a test body (once per test, the same rule as on PostgreSQL), queryguard runs EXPLAIN QUERY PLAN with the statement's real bound parameters on a fresh cursor of the test's own connection, and reads the plan rows (id, parent, notused, detail).

EXPLAIN QUERY PLAN prepares the statement but never runs it. It writes nothing, fires no trigger and starts no transaction. This was checked with sqlite3 in all three legacy isolation_level modes ("", "DEFERRED", None), with the autocommit attribute of Python 3.12 and later, and with aiosqlite: Connection.in_transaction and the data are the same with the check on and off (tests/test_sqlite_index_check.py).

Statements checked: SELECT, WITH (also WITH ... UPDATE and DELETE), UPDATE and DELETE. INSERT and executemany calls are not explained.

What is reported#

A finding says that a table is read in full although the statement narrows it, and what index would serve:

queryguard: missing index

  orders is read in full: SCAN with filter orders.customer_id = ?
  first at app/orders.py:11 in orders_of
  kind: missing-index  label: orders
  sql: SELECT orders.id, orders.customer_id FROM orders WHERE orders.customer_id = ?
  fix: CREATE INDEX ix_orders_customer_id ON orders (customer_id)
  alembic: op.create_index("ix_orders_customer_id", "orders", ["customer_id"])

When SQLite walks an existing index that the filter cannot use (for example it walks ix_users_created_at to satisfy an ORDER BY while the statement filters on status), the first line says so instead:

  users scans the whole index ix_users_created_at: the filter users.status = ? does not match the start of any index

The fix is still an index for the filtered column, which lets SQLite search instead of walking the index; whether it then also keeps the ORDER BY order is up to the planner.

Findings are missing-index findings: they follow queryguard_mode, queryguard_fail_on, queryguard_ignore, the baseline, SARIF, @pytest.mark.allow_missing_index and queryguard_index_ignore_tables exactly like the PostgreSQL ones. The table name is the declared name from sqlite_master, so the identity of a finding does not depend on the case used in the SQL.

Plan row Reported when
SCAN t the statement compares a column of t in WHERE or ON (equality, IN, IS, a range, BETWEEN, or a function of the column, which gets "an expression index may be needed"), and no index of t already leads with that column
SCAN t USING COVERING INDEX ix (or USING INDEX) an equality filter on a column that does not lead any index: the whole index is walked. A range filter or no filter is not reported (pagination walks an index on purpose). The finding says t scans the whole index ix: the filter ... does not match the start of any index, not that the table is read in full, and suggests an index for the filtered column
SEARCH t USING AUTOMATIC [COVERING] INDEX (c=?) always: SQLite built a temporary index at query time, so c is a column an index is missing on (kind is "join condition" or "filter")
SEARCH t USING INDEX ..., USING INTEGER PRIMARY KEY never

The fix hint is CREATE INDEX ix_<table>_<columns> ON <table> (<columns>) with the equality columns first and at most one range column. It is only given when the columns are certain, together with the Alembic op.create_index call (the index advisor's output; queryguard_index_advisor = false keeps only the CREATE INDEX, which SQLite needs a name for, unlike PostgreSQL). Otherwise the text is "add an index that matches this condition" (with "an expression index may be needed" for a function of the column). That happens for OR over several unindexed columns, for compound selects that read one table twice, and when the plan gives no readable column.

What is left alone (false-positive traps)#

These are covered by tests in tests/test_sqlite_index.py.

Differences from the PostgreSQL check#

Cost#

One EXPLAIN QUERY PLAN per distinct statement shape per test, plus a few catalog queries only when a scan needs judging. scripts/bench_overhead.py --index-check --statements 3000 --runs 7 on in-memory SQLite (Python 3.13, SQLAlchemy 2.1, one machine, median microseconds per statement):

loop measurement off listeners on plus index check index check alone
Core select by key 64.2 81.9 98.7 +16.8
ORM select by key 127.1 190.2 210.1 +19.8
Core, every statement a new shape 285.9 334.5 372.7 +38.2

A statement shape that was already explained in the test costs a text normalization and a set lookup (the first two rows); a new shape costs one more EXPLAIN QUERY PLAN on the same connection (the third row). These are timings of one machine and an in-memory database, not a promise.

Limits to keep in mind#

Edit this page on GitHub