TRENDING
Close-up of the Rosetta Stone showing the Demotic script above and the Greek script below, the same text written in two different scripts
October 6, 2026
How to Prepare Your Python Code for the Python 3.15 UTF-8 Default and Fix Windows Encoding Bugs
A row of green and grey fibre broadband street cabinets on a pavement beside a fence in Iver, England
October 6, 2026
BT’s TalkTalk Rescue Turns Telecom Continuity Into a New Merger-Control Ground
An ornate cast-iron wall mailbox with its door hanging open, stuffed with colorful flyers and a yellow flyer bulging out of the top slot
October 6, 2026
Google Stops Accepting Product Bug Reports for Its Open-Source Bounty, Citing Automated Submissions
Chronophotograph by Étienne-Jules Marey of a man riding a bicycle, showing five snapshots of the same ride taken at regular intervals
October 6, 2026
How to Find Slow Python Code With the Python 3.15 Tachyon Sampling Profiler
Close-up of an airport baggage tag reading Stockholm Arlanda and ARN
October 6, 2026
Cloudflare Traces Turns Distributed Tracing Into a Trust Decision at the Edge
06 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Shelves of old books fastened by iron chains in the Francis Trigge Chained Library in Grantham, England, a picture of data that can be read but not changed
How to Use frozendict in Python 3.15 to Freeze Config and Cache Dictionary Arguments
October 5, 2026
Row of capsule hotel pods with white pillows and folded blankets, each capsule an idle sleeper packed into a shared rack
Kubernetes Node Swap Turns Idle Agent Memory Into a Density Bet With No Wake-Up Test
October 5, 2026
Denmark’s oldest church book, from Holmens parish, open on a stack of books; its handwritten pages record births between 1617 and 1639
Denmark Says 8.8 Million Population Register Records Were Pulled Through One Company’s Lawful Access
October 5, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 226 Posts
News 228 Posts
Learning Hub 198 Posts
Home/Learning Hub/How to Read SQLite EXPLAIN QUERY PLAN and Build a Plan Gate for AI-Generated SQL
Learning Hub

How to Read SQLite EXPLAIN QUERY PLAN and Build a Plan Gate for AI-Generated SQL

Learn to read SQLite’s EXPLAIN QUERY PLAN, fix scans with the right indexes, and build a Python plan gate and step budget that catch slow AI-written SQL, with every number measured on a 500,000-row...

October 1, 2026 45 Min Read
39

Somewhere in your application, a query is reading every row of a table to find a handful of them. In development the table holds a few hundred rows and nobody notices. In production it holds millions, and that one query becomes the slowest thing you run. The cheapest way to catch it early is to ask the database how it plans to execute the query, and SQLite will tell you if you put EXPLAIN QUERY PLAN in front of the statement.

Table Of Content

  • Prerequisites
  • The idea in five minutes: how SQLite chooses a plan
  • Step 1: Build the lab database
  • Check that it worked
  • Step 2: Read your first plan, add an index, read it again
  • What you are looking at
  • Check that it worked
  • Step 3: Two columns, two orders, very different plans
  • Step 4: A covering index answers from the index alone
  • Step 5: Let an index do the sorting
  • Step 6: When the index exists but the plan still says SCAN
  • 6a. A function wrapped around the indexed column
  • 6b. LIKE, collation, and the leading wildcard
  • 6c. OR needs an index for every branch
  • Step 7: Indexes are not free
  • 7a. Every index slows every insert
  • 7b. An index on a column with few distinct values
  • 7c. A partial index covers only the rows you query
  • Step 8: The same query gets a different plan after ANALYZE
  • Step 9: Turn plan reading into a gate
  • The gate
  • The tests
  • Run it on a production-like index set
  • Step 10: Add a step budget for what a plan cannot see
  • Common mistake
  • Step 11: Try it on SQL written by AI models
  • What each layer caught
  • Common mistakes and gotchas
  • Judging a plan on an empty development database
  • Believing SEARCH means fast
  • Adding an index for every query
  • Parsing EXPLAIN QUERY PLAN in production code
  • Forgetting that LIKE is special
  • Verify the whole thing end to end
  • Where to go next
  • Sources and documentation

That habit matters more now that assistants and agents write SQL for us. A model that turns “show me the orders from 15 September” into a query has never seen your indexes, and a query can be perfectly valid and still read half a million rows to answer. In this tutorial you will learn to read a query plan, fix the plans that matter with the right indexes, and then turn what you learned into a small Python plan gate and step budget that you can run on AI-written SQL before it touches a real database.

Everything runs on your own machine with Python’s built-in sqlite3 module. You will build a 500,000-row lab database, run eleven short scripts, write a gate with ten passing tests, and see measured evidence for every rule of thumb, including the cases where an index exists and SQLite still refuses to use it. All timings come from one Windows 11 machine running SQLite 3.50.4, so your milliseconds will differ, but the plan lines and the ratios between timings should match.

Prerequisites

  • Python 3 with the standard sqlite3 module. I ran everything on Python 3.13.14. The code uses nothing exotic (dataclasses, regular expressions, the walrus operator), but 3.13.14 is the only version I tested.
  • pytest for the gate’s test suite (I used 9.1.1).
  • About 100 MB of free disk space. The database is 23 MB before any index, and some steps create several indexes in turn.
  • Basic SQL. You should be comfortable reading SELECT, WHERE, ORDER BY, LIMIT and JOIN. If subqueries feel shaky, our SQL subqueries guide is a good warm-up.
  • Optional: Ollama with the qwen2.5:1.5b and qwen3.5:4b models, only if you want to regenerate the AI-written queries in Step 11. The answers I got are frozen in a file, so you can skip this.

Create a folder, a virtual environment, and check which SQLite version your Python carries. On macOS or Linux activate the environment with source .venv/bin/activate instead of the PowerShell line.

mkdir plan-lab
cd plan-lab
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install pytest
python -c "import sqlite3; print(sqlite3.sqlite_version)"
3.50.4

If yours prints a different version, the plans in this article should still look familiar, but their exact wording may differ. That is not a nuisance to ignore: it is the central risk of Step 9, where we write code that reads plans.

The idea in five minutes: how SQLite chooses a plan

SQL is declarative. You describe the rows you want, and the database’s query planner decides how to fetch them. For each table in a query the planner picks a strategy. The slow strategy is a full table scan: read every row and test the WHERE clause against each one. The fast strategy is an index search: jump straight to the matching rows through a second, sorted structure called an index.

Think of a phone book. It is sorted by last name and then first name, so finding every Garcia is quick, and finding the phone number for one Alex Garcia is quicker still. Finding everyone called Alex, whatever their last name, means reading the whole book. A database index behaves the same way. SQLite’s own query planning guide puts it this way: “A multi-column index follows the same pattern as a single-column index; the indexed columns are added in front of the rowid.” It adds: “The left-most column is the primary key used for ordering the rows in the index. The second column is used to break ties in the left-most column.” Keep that ordering in mind, because Step 3 and Step 6 both depend on it.

You do not have to guess which strategy the planner picked. The EXPLAIN documentation says that putting EXPLAIN QUERY PLAN before a statement “causes the SQL statement to behave as a query and to return information about how the SQL statement would have operated if the EXPLAIN keyword or phrase had been omitted.” In other words, you get the plan without running the query. The EXPLAIN QUERY PLAN guide adds that “The output from EXPLAIN QUERY PLAN shows how the query is actually evaluated, not how it is specified in the SQL statement,” so it is the ground truth about what the planner decided.

Four words appear again and again in plans, so let us fix their meaning now:

  • SCAN means SQLite reads through a whole table or a whole index.
  • SEARCH means SQLite visits only part of it, usually by binary-searching an index.
  • COVERING INDEX means the index alone contains every column the query needs, so the table is never touched.
  • TEMP B-TREE means SQLite builds a temporary sorted structure to satisfy ORDER BY, GROUP BY or DISTINCT.

One warning before we start, and we will come back to it. The EXPLAIN QUERY PLAN guide says: “The data returned by the EXPLAIN QUERY PLAN command is intended for interactive debugging only. The output format may change between SQLite releases. Applications should not depend on the output format of the EXPLAIN QUERY PLAN command.” Reading plans yourself is safe. Writing software that parses them needs extra care, and Step 9 shows what that care looks like.

Step 1: Build the lab database

We need a table big enough that a bad plan hurts. The script below builds shop.db with 20,000 customers and 500,000 orders. It uses a seeded random generator, so every reader gets identical data, and it deliberately creates no indexes and runs no ANALYZE. Two details matter later. Order ids grow with time, like a real append-only orders table. And order statuses are skewed: 62% are delivered while only 1% are refunded, because real data is rarely uniform and the skew will show up in Step 7.

First the shared helpers. You will import them in every later script, so it is worth knowing what each does.

# common.py
"""common.py - helpers shared by every step."""
import sqlite3
import statistics
import time

DB = "shop.db"


def connect(path=DB):
    return sqlite3.connect(path)


def plan_rows(conn, sql, params=()):
    """EXPLAIN QUERY PLAN as (id, parent, detail) tuples."""
    return [(r[0], r[1], r[3]) for r in conn.execute("EXPLAIN QUERY PLAN " + sql, params)]


def plan(conn, sql, params=()):
    """Just the detail text of each plan node."""
    return [detail for _, _, detail in plan_rows(conn, sql, params)]


def show(conn, sql, params=()):
    """Print the query on one line, then its plan indented by tree depth."""
    print(" ".join(sql.split()))
    depth = {}
    for node_id, parent, detail in plan_rows(conn, sql, params):
        depth[node_id] = depth.get(parent, -1) + 1
        print("   " + "  " * depth[node_id] + detail)


def bench(conn, sql, params=(), repeat=7):
    """Median milliseconds to run the query and fetch every row (one warm-up run is discarded)."""
    conn.execute(sql, params).fetchall()
    times = []
    for _ in range(repeat):
        start = time.perf_counter()
        conn.execute(sql, params).fetchall()
        times.append((time.perf_counter() - start) * 1000)
    return statistics.median(times)


def bench_many(conn, sql, param_list, repeat=5):
    """Median milliseconds to run the query once for every parameter set in param_list."""
    for params in param_list:
        conn.execute(sql, params).fetchall()
    times = []
    for _ in range(repeat):
        start = time.perf_counter()
        for params in param_list:
            conn.execute(sql, params).fetchall()
        times.append((time.perf_counter() - start) * 1000)
    return statistics.median(times)


def used_pages(conn):
    return conn.execute("PRAGMA page_count").fetchone()[0] - conn.execute("PRAGMA freelist_count").fetchone()[0]


def create_index(conn, ddl):
    """Run a CREATE INDEX statement. Returns (seconds taken, megabytes of database added)."""
    page_size = conn.execute("PRAGMA page_size").fetchone()[0]
    before = used_pages(conn)
    start = time.perf_counter()
    conn.execute(ddl)
    seconds = time.perf_counter() - start
    return seconds, (used_pages(conn) - before) * page_size / 1e6


def reset(conn):
    """Back to a clean slate: drop every index we created and forget any ANALYZE statistics."""
    for (name,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL").fetchall():
        conn.execute(f'DROP INDEX "{name}"')
    if conn.execute("SELECT 1 FROM sqlite_master WHERE name = 'sqlite_stat1'").fetchone():
        conn.execute("DELETE FROM sqlite_stat1")
    conn.commit()

plan_rows runs EXPLAIN QUERY PLAN and returns the nodes. show prints a query followed by its plan, indented by tree depth. bench runs a query seven times after one discarded warm-up and returns the median in milliseconds; it fetches every row, so we time the work of delivering results and not just starting the query. bench_many does the same for a batch of lookups. create_index runs a CREATE INDEX statement and reports how long it took and how many megabytes it added, measured as pages in use (page_count minus freelist_count) so that reused free pages do not hide the growth. reset drops every index and forgets any saved statistics, so each step starts from the same clean slate.

Note that plan_rows forwards the query’s parameters to EXPLAIN QUERY PLAN. If you forget to, SQLite raises Incorrect number of bindings supplied, because the explained statement still contains its ? placeholders.

Now the database builder:

# build_db.py
"""build_db.py - build shop.db, a deterministic sample database (20,000 customers, 500,000 orders)."""
import random
import sqlite3
import sys
from datetime import datetime, timedelta

N_CUSTOMERS = 20_000
N_ORDERS = 500_000
START = datetime(2025, 1, 1)
END = datetime(2026, 9, 30)
SPAN = int((END - START).total_seconds())

LAST = ["garcia", "smith", "chen", "kumar", "nguyen", "muller", "silva", "rossi", "ivanov", "tanaka"]
FIRST = ["alex", "sam", "maria", "li", "priya", "tom", "ana", "luca", "olga", "kenji"]
DOMAINS = ["example.com", "example.org", "mail.test"]
COUNTRIES = ["US", "DE", "IN", "BR", "JP", "FR", "GB", "CA"]
TIERS = ["free", "pro", "enterprise"]
STATUSES = ["delivered", "shipped", "paid", "pending", "cancelled", "refunded"]
STATUS_WEIGHTS = [62, 14, 10, 6, 7, 1]

SCHEMA = """
CREATE TABLE customers (
    id         INTEGER PRIMARY KEY,
    email      TEXT NOT NULL,
    country    TEXT NOT NULL,
    tier       TEXT NOT NULL,
    created_at TEXT NOT NULL
);
CREATE TABLE orders (
    id          INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    status      TEXT NOT NULL,
    total_cents INTEGER NOT NULL,
    created_at  TEXT NOT NULL
);
"""


def stamp(seconds):
    return (START + timedelta(seconds=seconds)).strftime("%Y-%m-%d %H:%M:%S")


def customer_rows(rng):
    for i in range(1, N_CUSTOMERS + 1):
        email = f"{rng.choice(LAST)}.{rng.choice(FIRST)}{i}@{rng.choice(DOMAINS)}"
        joined = (datetime(2024, 1, 1) + timedelta(seconds=rng.randrange(365 * 86400))).strftime("%Y-%m-%d %H:%M:%S")
        yield (i, email, rng.choice(COUNTRIES), rng.choices(TIERS, [80, 15, 5])[0], joined)


def order_rows(rng):
    # ids grow with time, like a real append-only orders table
    moments = sorted(rng.randrange(SPAN) for _ in range(N_ORDERS))
    for sec in moments:
        yield (
            rng.randint(1, N_CUSTOMERS),
            rng.choices(STATUSES, STATUS_WEIGHTS)[0],
            max(199, int(rng.lognormvariate(8.0, 0.9))),
            stamp(sec),
        )


def main(path="shop.db"):
    rng = random.Random(2026)
    conn = sqlite3.connect(path)
    conn.executescript(SCHEMA)
    conn.executemany("INSERT INTO customers VALUES (?, ?, ?, ?, ?)", customer_rows(rng))
    conn.executemany(
        "INSERT INTO orders (customer_id, status, total_cents, created_at) VALUES (?, ?, ?, ?)",
        order_rows(rng),
    )
    conn.commit()
    for table in ("customers", "orders"):
        print(table, conn.execute(f"SELECT count(*) FROM {table}").fetchone()[0], "rows")
    pages = conn.execute("PRAGMA page_count").fetchone()[0]
    size = conn.execute("PRAGMA page_size").fetchone()[0]
    print("sqlite", sqlite3.sqlite_version, "| page_size", size, "| page_count", pages)
    print("indexes:", conn.execute("SELECT count(*) FROM sqlite_master WHERE type = 'index'").fetchone()[0])
    conn.close()


if __name__ == "__main__":
    main(*sys.argv[1:])

Run it:

python build_db.py
customers 20000 rows
orders 500000 rows
sqlite 3.50.4 | page_size 4096 | page_count 5618
indexes: 0

Check that it worked

Your row counts must be exactly 20000 and 500000, and the last line must say indexes: 0. The page count (5,618 pages of 4,096 bytes, about 23 MB) is also deterministic on the same SQLite version. If the script takes more than a few seconds, something is wrong with disk speed, not with the script.

Step 2: Read your first plan, add an index, read it again

Our first query looks up one customer’s orders. We will explain it, time it, create an index, and explain and time it again.

# step2_first_plan.py
"""Step 2: read a plan, add an index, read the plan again."""
from common import bench, connect, create_index, reset, show

conn = connect()
reset(conn)
QUERY = "SELECT id, total_cents FROM orders WHERE customer_id = ?"

print("--- the raw rows: (id, parent, notused, detail)")
for row in conn.execute("EXPLAIN QUERY PLAN " + QUERY, (4242,)):
    print("   ", row)

print("--- without an index")
show(conn, QUERY, (4242,))
print(f"    {len(conn.execute(QUERY, (4242,)).fetchall())} rows, {bench(conn, QUERY, (4242,)):.3f} ms")

seconds, megabytes = create_index(conn, "CREATE INDEX idx_orders_customer ON orders(customer_id)")
print(f"--- CREATE INDEX took {seconds:.2f} s and added {megabytes:.1f} MB")

show(conn, QUERY, (4242,))
print(f"    {len(conn.execute(QUERY, (4242,)).fetchall())} rows, {bench(conn, QUERY, (4242,)):.3f} ms")
reset(conn)
python step2_first_plan.py
--- the raw rows: (id, parent, notused, detail)
    (2, 0, 216, 'SCAN orders')
--- without an index
SELECT id, total_cents FROM orders WHERE customer_id = ?
   SCAN orders
    27 rows, 23.659 ms
--- CREATE INDEX took 0.16 s and added 5.5 MB
SELECT id, total_cents FROM orders WHERE customer_id = ?
   SEARCH orders USING INDEX idx_orders_customer (customer_id=?)
    27 rows, 0.043 ms

What you are looking at

The first block shows the raw rows from EXPLAIN QUERY PLAN. Each is a tuple of four fields. The guide describes them as “an integer node id, an integer parent id, an auxiliary integer field that is not currently used, and a description of the node.” The first two fields let a plan be a tree: our single node has id 2 and parent 0. The third field is the unused one (it holds 216 here, and you can ignore it). Only the fourth field, the description, matters day to day, and that is what show prints.

Without an index the description is SCAN orders. SQLite had no choice but to read all 500,000 rows to find 27 of them, which took about 23.7 ms on my machine. The guide says SCAN “is used for a full-table scan, including cases where SQLite iterates through all records in a table in an order defined by an index,” while SEARCH “indicates that only a subset of the table rows are visited.”

After CREATE INDEX idx_orders_customer ON orders(customer_id) the description becomes SEARCH orders USING INDEX idx_orders_customer (customer_id=?). Read it left to right: SEARCH (only part of the table), the index used, and in parentheses the WHERE terms that index can answer. The same query now takes about 0.043 ms, roughly 550 times faster. The price was 0.16 seconds to build the index and 5.5 MB of disk, about a quarter of the size of the database.

Check that it worked

You should see SCAN in the first plan and SEARCH in the second, and the same row count (27) both times. If you see SEARCH in both, an index from an earlier experiment survived; run python -c "import common; common.reset(common.connect())" and try again.

Step 3: Two columns, two orders, very different plans

Now a query with two conditions: refunded orders since 1 June 2025. One condition is an equality (status = 'refunded') and one is a range (created_at >= '2025-06-01'). A two-column index could serve both, but the order of its columns matters, so we will try both orders.

# step3_composite.py
"""Step 3: the same two columns in two orders."""
from common import bench, connect, create_index, reset, show

conn = connect()
QUERY = """SELECT id, total_cents FROM orders
           WHERE status = 'refunded' AND created_at >= '2025-06-01'"""

for name, columns in [("idx_range_first", "created_at, status"), ("idx_equality_first", "status, created_at")]:
    reset(conn)
    seconds, megabytes = create_index(conn, f"CREATE INDEX {name} ON orders({columns})")
    print(f"=== CREATE INDEX {name} ON orders({columns})  ({megabytes:.1f} MB)")
    show(conn, QUERY)
    print(f"    {len(conn.execute(QUERY).fetchall())} rows, {bench(conn, QUERY):.3f} ms")
reset(conn)
python step3_composite.py
=== CREATE INDEX idx_range_first ON orders(created_at, status)  (18.7 MB)
SELECT id, total_cents FROM orders WHERE status = 'refunded' AND created_at >= '2025-06-01'
   SEARCH orders USING INDEX idx_range_first (created_at>?)
    3791 rows, 29.016 ms
=== CREATE INDEX idx_equality_first ON orders(status, created_at)  (18.7 MB)
SELECT id, total_cents FROM orders WHERE status = 'refunded' AND created_at >= '2025-06-01'
   SEARCH orders USING INDEX idx_equality_first (status=? AND created_at>?)
    3791 rows, 8.577 ms

Both indexes contain the same data and cost the same 18.7 MB, yet the first query takes 29.0 ms and the second 8.6 ms, about 3.4 times faster. The plan text explains why. With (created_at, status) the index consumes only one term, (created_at>?), so SQLite walks every index entry from June onwards (roughly three quarters of the table) and checks the status as it goes. With (status, created_at) it consumes both, (status=? AND created_at>?): it jumps to the refunded entries and reads only the slice after the date.

SQLite’s optimizer overview states the rule for an index on columns a, b, c and d, used by a query with a=5 AND b IN (1,2,3) AND c>12 AND d='hello': “Only columns a, b, and c of the index would be usable. The d column would not be usable because it occurs to the right of c and c is constrained only by inequalities.” The practical rule is to put equality columns first, then one range column. Columns after a range column no longer narrow the search.

Step 4: A covering index answers from the index alone

A plain index finds the matching rows, but SQLite must still visit the table to read the other columns the query asks for. If the index also stores those columns, the table visit disappears. The planning guide explains: “Because all of the information needed is in the covering index, SQLite never needs to consult the original table in order to find the price.” We will time 300 customer lookups with a plain and a covering index.

# step4_covering.py
"""Step 4: a covering index answers the query from the index alone."""
import random

from common import bench_many, connect, create_index, reset, show

conn = connect()
rng = random.Random(7)
IDS = [(rng.randint(1, 20_000),) for _ in range(300)]
QUERY = "SELECT status, total_cents FROM orders WHERE customer_id = ?"

for ddl in (
    "CREATE INDEX idx_plain ON orders(customer_id)",
    "CREATE INDEX idx_covering ON orders(customer_id, status, total_cents)",
):
    reset(conn)
    seconds, megabytes = create_index(conn, ddl)
    print(f"=== {ddl}  ({megabytes:.1f} MB)")
    show(conn, QUERY, IDS[0])
    print(f"    300 lookups: {bench_many(conn, QUERY, IDS):.1f} ms")
reset(conn)
python step4_covering.py
=== CREATE INDEX idx_plain ON orders(customer_id)  (5.5 MB)
SELECT status, total_cents FROM orders WHERE customer_id = ?
   SEARCH orders USING INDEX idx_plain (customer_id=?)
    300 lookups: 46.0 ms
=== CREATE INDEX idx_covering ON orders(customer_id, status, total_cents)  (11.6 MB)
SELECT status, total_cents FROM orders WHERE customer_id = ?
   SEARCH orders USING COVERING INDEX idx_covering (customer_id=?)
    300 lookups: 11.6 ms

The plan wording changes from USING INDEX to USING COVERING INDEX, and 300 lookups drop from 46.0 ms to 11.6 ms, about four times faster. The trade-off is size: the covering index is 11.6 MB against 5.5 MB because it stores two extra columns for every row. Covering indexes are worth it for hot, narrow queries, not as a default for every query you write.

Step 5: Let an index do the sorting

A query with ORDER BY needs rows in order. Without a suitable index SQLite must collect rows and sort them in a temporary structure. The guide notes that “Using an index is almost always much more efficient than performing a sort.” We will test two common cases: the newest 20 orders overall, and one customer’s newest 10 orders.

# step5_order_by.py
"""Step 5: let an index do the sorting."""
import random

from common import bench, bench_many, connect, create_index, reset, show

conn = connect()
NEWEST = "SELECT id, created_at FROM orders ORDER BY created_at DESC LIMIT 20"
PAGE = "SELECT id, created_at FROM orders WHERE customer_id = ? ORDER BY created_at DESC LIMIT 10"
rng = random.Random(7)
IDS = [(rng.randint(1, 20_000),) for _ in range(300)]

print("##### the 20 newest orders")
reset(conn)
show(conn, NEWEST)
print(f"    {bench(conn, NEWEST):.3f} ms")
create_index(conn, "CREATE INDEX idx_created ON orders(created_at)")
show(conn, NEWEST)
print(f"    {bench(conn, NEWEST):.3f} ms")

print("##### one customer's 10 newest orders, 300 customers")
for ddl in (
    "CREATE INDEX idx_customer ON orders(customer_id)",
    "CREATE INDEX idx_customer_created ON orders(customer_id, created_at)",
):
    reset(conn)
    create_index(conn, ddl)
    print("===", ddl)
    show(conn, PAGE, IDS[0])
    print(f"    300 lookups: {bench_many(conn, PAGE, IDS):.1f} ms")
reset(conn)
python step5_order_by.py
##### the 20 newest orders
SELECT id, created_at FROM orders ORDER BY created_at DESC LIMIT 20
   SCAN orders
   USE TEMP B-TREE FOR ORDER BY
    94.961 ms
SELECT id, created_at FROM orders ORDER BY created_at DESC LIMIT 20
   SCAN orders USING COVERING INDEX idx_created
    0.041 ms
##### one customer's 10 newest orders, 300 customers
=== CREATE INDEX idx_customer ON orders(customer_id)
SELECT id, created_at FROM orders WHERE customer_id = ? ORDER BY created_at DESC LIMIT 10
   SEARCH orders USING INDEX idx_customer (customer_id=?)
   USE TEMP B-TREE FOR ORDER BY
    300 lookups: 45.4 ms
=== CREATE INDEX idx_customer_created ON orders(customer_id, created_at)
SELECT id, created_at FROM orders WHERE customer_id = ? ORDER BY created_at DESC LIMIT 10
   SEARCH orders USING COVERING INDEX idx_customer_created (customer_id=?)
    300 lookups: 10.0 ms

Without an index, the newest-20 query scans all 500,000 rows and sorts them (USE TEMP B-TREE FOR ORDER BY) just to keep 20, taking 95 ms. With an index on created_at, SQLite can read the index from the newest end, stop after 20 entries, and finish in 0.041 ms, more than 2,000 times faster.

Look closely at the second plan: SCAN orders USING COVERING INDEX idx_created. It still says SCAN, because SQLite is walking an index in order, but it stops after 20 entries because of LIMIT 20. This is a good scan. Remember it, because the gate in Step 9 has to tell this kind of scan from a bad one.

For the per-customer query, an index on customer_id alone still needs a temporary sort. An index on (customer_id, created_at) serves the filter and the order at once, so the sort node vanishes and 300 lookups fall from 45.4 ms to 10.0 ms. It is the same equality-first rule from Step 3, now with the ORDER BY column in second place.

Step 6: When the index exists but the plan still says SCAN

The most frustrating plan is the one where you did create the index and SQLite ignores it. Three causes account for most cases.

# step6_ignored.py
"""Step 6: the index exists, but the plan still says SCAN."""
from common import bench, connect, create_index, reset, show

conn = connect()


def run(sql, params=()):
    show(conn, sql, params)
    print(f"    {len(conn.execute(sql, params).fetchall())} rows, {bench(conn, sql, params):.3f} ms")


print("##### 6a. a function wrapped around the indexed column")
reset(conn)
create_index(conn, "CREATE INDEX idx_created ON orders(created_at)")
run("SELECT id, total_cents FROM orders WHERE substr(created_at, 1, 10) = '2026-09-15'")
print("--- fix 1: rewrite as a range")
run("SELECT id, total_cents FROM orders WHERE created_at >= '2026-09-15' AND created_at < '2026-09-16'")
print("--- fix 2: index the expression itself")
reset(conn)
create_index(conn, "CREATE INDEX idx_day ON orders(substr(created_at, 1, 10))")
run("SELECT id, total_cents FROM orders WHERE substr(created_at, 1, 10) = '2026-09-15'")

print("##### 6b. LIKE and collation")
reset(conn)
create_index(conn, "CREATE INDEX idx_email ON customers(email)")
print("--- plain index (BINARY collation)")
run("SELECT id FROM customers WHERE email LIKE 'garcia.alex%'")
run("SELECT id FROM customers WHERE email GLOB 'garcia.alex*'")
run("SELECT id FROM customers WHERE email >= 'garcia.alex' AND email < 'garcia.aley'")
run("SELECT id FROM customers WHERE email LIKE '%@example.org'")
reset(conn)
create_index(conn, "CREATE INDEX idx_email_nocase ON customers(email COLLATE NOCASE)")
print("--- index declared COLLATE NOCASE")
run("SELECT id FROM customers WHERE email LIKE 'garcia.alex%'")

print("##### 6c. OR needs an index for every branch")
OR_QUERY = "SELECT id FROM orders WHERE customer_id = 4242 OR status = 'refunded'"
reset(conn)
create_index(conn, "CREATE INDEX idx_customer ON orders(customer_id)")
run(OR_QUERY)
create_index(conn, "CREATE INDEX idx_status ON orders(status)")
run(OR_QUERY)
reset(conn)
python step6_ignored.py

6a. A function wrapped around the indexed column

##### 6a. a function wrapped around the indexed column
SELECT id, total_cents FROM orders WHERE substr(created_at, 1, 10) = '2026-09-15'
   SCAN orders
    770 rows, 33.266 ms
--- fix 1: rewrite as a range
SELECT id, total_cents FROM orders WHERE created_at >= '2026-09-15' AND created_at < '2026-09-16'
   SEARCH orders USING INDEX idx_created (created_at>? AND created_at<?)
    770 rows, 0.284 ms
--- fix 2: index the expression itself
SELECT id, total_cents FROM orders WHERE substr(created_at, 1, 10) = '2026-09-15'
   SEARCH orders USING INDEX idx_day (<expr>=?)
    770 rows, 0.289 ms

The index on created_at holds timestamps, not the first ten characters of them, so substr(created_at, 1, 10) = '2026-09-15' cannot use it. The plan is a plain SCAN orders at 33.3 ms. There are two fixes. Rewrite the filter as a range on the raw column, which uses the existing index and takes 0.28 ms. Or index the expression itself, which makes the original query fast (0.29 ms) and shows up in the plan as (<expr>=?).

The expression index documentation adds a limit worth remembering: the planner uses an expression index when the expression appears in the query “exactly as it is written in the CREATE INDEX statement,” and “The query planner does not do algebra.” An index on x+y will not serve y+x.

6b. LIKE, collation, and the leading wildcard

##### 6b. LIKE and collation
--- plain index (BINARY collation)
SELECT id FROM customers WHERE email LIKE 'garcia.alex%'
   SCAN customers USING COVERING INDEX idx_email
    187 rows, 0.525 ms
SELECT id FROM customers WHERE email GLOB 'garcia.alex*'
   SEARCH customers USING COVERING INDEX idx_email (email>? AND email<?)
    187 rows, 0.078 ms
SELECT id FROM customers WHERE email >= 'garcia.alex' AND email < 'garcia.aley'
   SEARCH customers USING COVERING INDEX idx_email (email>? AND email<?)
    187 rows, 0.073 ms
SELECT id FROM customers WHERE email LIKE '%@example.org'
   SCAN customers USING COVERING INDEX idx_email
    6622 rows, 2.370 ms
--- index declared COLLATE NOCASE
SELECT id FROM customers WHERE email LIKE 'garcia.alex%'
   SEARCH customers USING COVERING INDEX idx_email_nocase (email>? AND email<?)
    187 rows, 0.074 ms

Searching for emails that start with garcia.alex is a prefix search, which an index can answer. Yet LIKE with a plain index produces SCAN customers USING COVERING INDEX idx_email, while GLOB and the hand-written range both produce a SEARCH with (email>? AND email<?), about seven times faster (0.078 ms against 0.525 ms). Be aware that GLOB and the range compare case-sensitively while LIKE does not, so they are not drop-in replacements if your data mixes cases. The reason is in the optimizer overview: “The LIKE operator is case insensitive by default because this is what the SQL standard requires,” and “For the LIKE operator, if case_sensitive_like mode is enabled then the column must be indexed using the built-in BINARY collating sequence, or if case_sensitive_like mode is disabled then the column must be indexed using the built-in NOCASE collating sequence.” Our index uses the default BINARY collation while LIKE is case-insensitive, so the optimization is off. Declaring the index COLLATE NOCASE turns it on: the last plan above is a SEARCH at 0.074 ms.

A pattern that starts with a wildcard, like '%@example.org', can never use the index, because the sorted order says nothing about how a value ends. It scans every entry whatever the collation, and returns 6,622 rows in 2.4 ms on this small table.

6c. OR needs an index for every branch

##### 6c. OR needs an index for every branch
SELECT id FROM orders WHERE customer_id = 4242 OR status = 'refunded'
   SCAN orders
    5020 rows, 32.182 ms
SELECT id FROM orders WHERE customer_id = 4242 OR status = 'refunded'
   MULTI-INDEX OR
     INDEX 1
       SEARCH orders USING INDEX idx_customer (customer_id=?)
     INDEX 2
       SEARCH orders USING INDEX idx_status (status=?)
    5020 rows, 1.288 ms

The planning guide states: “Multi-column indices only work if the constraint terms in the WHERE clause of the query are connected by AND.” With an index only on customer_id, a query that ORs it with a status test must scan the table (32.2 ms). Add an index on status and SQLite switches to MULTI-INDEX OR, searching each index and combining the results in 1.29 ms, 25 times faster. This plan is also the first one in this tutorial with nested lines, so notice the tree: INDEX 1 and INDEX 2 are children of the MULTI-INDEX OR node, which is exactly what the parent id in the raw rows encodes. show indents by that depth.

Step 7: Indexes are not free

It is tempting to index everything after seeing those numbers. Do not. Each index costs disk, slows every write, and sometimes does not help at all.

# step7_costs.py
"""Step 7: what an index costs - writes, disk, and queries it cannot help."""
import random
import sqlite3
import statistics
import time

from build_db import SCHEMA, STATUSES
from common import bench, connect, create_index, reset, show

print("##### 7a. insert cost, 100,000 rows into an in-memory copy of the orders table")
rng = random.Random(3)
ROWS = [
    (rng.randint(1, 20_000), rng.choice(STATUSES), rng.randint(199, 20_000), f"2026-{rng.randint(1, 9):02d}-{rng.randint(1, 28):02d} 12:00:00")
    for _ in range(100_000)
]
INDEXES = [
    "CREATE INDEX i1 ON orders(customer_id)",
    "CREATE INDEX i2 ON orders(status, created_at)",
    "CREATE INDEX i3 ON orders(created_at)",
    "CREATE INDEX i4 ON orders(customer_id, created_at)",
    "CREATE INDEX i5 ON orders(customer_id, status, total_cents)",
]
baseline = None
for count in (0, 1, 3, 5):
    runs = []
    for _ in range(3):
        mem = sqlite3.connect(":memory:")
        mem.executescript(SCHEMA)
        for ddl in INDEXES[:count]:
            mem.execute(ddl)
        start = time.perf_counter()
        mem.executemany("INSERT INTO orders (customer_id, status, total_cents, created_at) VALUES (?, ?, ?, ?)", ROWS)
        mem.commit()
        runs.append(time.perf_counter() - start)
        mem.close()
    seconds = statistics.median(runs)
    baseline = baseline or seconds
    print(f"    {count} indexes: {seconds:.3f} s  ({seconds / baseline:.1f}x the no-index time)")

print("##### 7b. an index on a column with few distinct values")
conn = connect()
reset(conn)
QUERY = "SELECT id, total_cents FROM orders WHERE status = 'delivered'"
show(conn, QUERY)
print(f"    {len(conn.execute(QUERY).fetchall())} rows, {bench(conn, QUERY):.1f} ms")
create_index(conn, "CREATE INDEX idx_status ON orders(status)")
show(conn, QUERY)
print(f"    {len(conn.execute(QUERY).fetchall())} rows, {bench(conn, QUERY):.1f} ms")

print("##### 7c. a partial index covers only the rows you query")
reset(conn)
_, full_mb = create_index(conn, "CREATE INDEX idx_full ON orders(status, created_at)")
conn.execute("DROP INDEX idx_full")
_, partial_mb = create_index(conn, "CREATE INDEX idx_pending ON orders(created_at) WHERE status = 'pending'")
print(f"    full index {full_mb:.1f} MB, partial index {partial_mb:.1f} MB")
TAIL = "ORDER BY created_at LIMIT 20"
for sql, params in [
    (f"SELECT id, created_at FROM orders WHERE status = 'pending' {TAIL}", ()),
    (f"SELECT id, created_at FROM orders WHERE status = ? {TAIL}", ("pending",)),
    (f"SELECT id, created_at FROM orders WHERE status IN ('pending') {TAIL}", ()),
    (f"SELECT id, created_at FROM orders WHERE status = 'paid' {TAIL}", ()),
]:
    show(conn, sql, params)
    print(f"    {bench(conn, sql, params):.3f} ms")
reset(conn)
python step7_costs.py

7a. Every index slows every insert

##### 7a. insert cost, 100,000 rows into an in-memory copy of the orders table
    0 indexes: 0.045 s  (1.0x the no-index time)
    1 indexes: 0.083 s  (1.8x the no-index time)
    3 indexes: 0.214 s  (4.7x the no-index time)
    5 indexes: 0.324 s  (7.1x the no-index time)

Inserting 100,000 rows into an in-memory copy of the orders table took 0.045 s with no indexes, 0.083 s with one, 0.214 s with three and 0.324 s with five: more than seven times slower. Every insert must update every index. The test runs in memory so the numbers isolate CPU work; I did not measure a disk-backed table, where the I/O cost would come on top.

7b. An index on a column with few distinct values

##### 7b. an index on a column with few distinct values
SELECT id, total_cents FROM orders WHERE status = 'delivered'
   SCAN orders
    310661 rows, 166.7 ms
SELECT id, total_cents FROM orders WHERE status = 'delivered'
   SEARCH orders USING INDEX idx_status (status=?)
    310661 rows, 199.8 ms

A query for status = 'delivered' returns 310,661 rows, 62% of the table. With an index, the plan says SEARCH, yet it ran in 199.8 ms against 166.7 ms for the plain scan, about 20% slower, most likely because following 310,661 index entries back to table rows is more work than reading the table in order. A plan that says SEARCH is not a promise of speed; it only says SQLite is using an index. We will meet this again in Step 11.

7c. A partial index covers only the rows you query

##### 7c. a partial index covers only the rows you query
    full index 18.7 MB, partial index 0.8 MB
SELECT id, created_at FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 20
   SCAN orders USING COVERING INDEX idx_pending
    0.081 ms
SELECT id, created_at FROM orders WHERE status = ? ORDER BY created_at LIMIT 20
   SCAN orders USING COVERING INDEX idx_pending
    0.091 ms
SELECT id, created_at FROM orders WHERE status IN ('pending') ORDER BY created_at LIMIT 20
   SCAN orders
   USE TEMP B-TREE FOR ORDER BY
    35.579 ms
SELECT id, created_at FROM orders WHERE status = 'paid' ORDER BY created_at LIMIT 20
   SCAN orders
   USE TEMP B-TREE FOR ORDER BY
    41.886 ms

If you only ever look up one kind of row, index only those rows. Here the work queue of pending orders is 6% of the table. A full index on (status, created_at) takes 18.7 MB, while CREATE INDEX ... ON orders(created_at) WHERE status = 'pending' takes 0.8 MB, about 23 times less, and writes to rows that are not pending no longer have to maintain it.

The catch is in the four plans. status = 'pending' uses the partial index (0.081 ms). So does status = ? with the value bound to 'pending' (0.091 ms), although the partial index page does not discuss bound parameters in queries, so treat that as an observation about SQLite 3.50.4 and test it on your own version. But status IN ('pending') falls back to a scan plus a sort (35.6 ms), and status = 'paid' cannot use the index at all (41.9 ms). The documentation is blunt: “The terms in W and X must match exactly. SQLite does not do algebra to try to get them to look the same.” Here W is the query’s WHERE clause and X is the index’s.

Step 8: The same query gets a different plan after ANALYZE

SQLite plans from whatever it knows about your data. By default it knows little. The ANALYZE command gathers statistics about your tables and indexes, such as row counts and how many rows share each leading value, and stores them in a table called sqlite_stat1. Our next script builds an index on (status, created_at) and then asks for recent orders by date alone, with no mention of status.

# step8_analyze.py
"""Step 8: the same schema, the same query, a different plan after ANALYZE."""
from common import bench, connect, create_index, reset, show

conn = connect()
reset(conn)
create_index(conn, "CREATE INDEX idx_status_created ON orders(status, created_at)")
QUERY = "SELECT id FROM orders WHERE created_at >= '2026-09-29'"

print("--- before ANALYZE")
show(conn, QUERY)
print(f"    {bench(conn, QUERY):.3f} ms")

conn.execute("ANALYZE")
print("--- sqlite_stat1 after ANALYZE")
for row in conn.execute("SELECT tbl, idx, stat FROM sqlite_stat1 ORDER BY tbl"):
    print("   ", row)

print("--- after ANALYZE")
show(conn, QUERY)
print(f"    {bench(conn, QUERY):.3f} ms")
reset(conn)
python step8_analyze.py
--- before ANALYZE
SELECT id FROM orders WHERE created_at >= '2026-09-29'
   SCAN orders USING COVERING INDEX idx_status_created
    22.982 ms
--- sqlite_stat1 after ANALYZE
    ('customers', None, '20000')
    ('orders', 'idx_status_created', '500000 83334 1')
--- after ANALYZE
SELECT id FROM orders WHERE created_at >= '2026-09-29'
   SEARCH orders USING COVERING INDEX idx_status_created (ANY(status) AND created_at>?)
    0.215 ms

Before ANALYZE the plan is a full scan of the index (23.0 ms). After it, the plan becomes SEARCH ... (ANY(status) AND created_at>?) and takes 0.215 ms, about 100 times faster, with no change to the schema or the query. SQLite has switched to a skip-scan: it hops from one status value to the next and range-searches the date within each. The optimizer overview introduces it with “However, in some cases, SQLite is able to use an index even if the first few columns of the index are omitted from the WHERE clause but later columns are included,” and says why statistics matter: “The only way that SQLite can know that there are many duplicates in the left-most columns of an index is if the ANALYZE command has been run on the database.” It concludes: “Hence, a skip-scan is never used on a database that has not been analyzed.” Without statistics, in its words, “the default guess is that there are an average of 10 duplicates for every value in the left-most column of the index.”

The saved statistics are worth reading once. The file format documentation explains the stat column: “The first integer in this list is the approximate number of rows in the index.” “The second integer is the approximate number of rows in the index that have the same value in the first column of the index.” “The third integer is the number of rows in the index that have the same value for the first two columns.” So '500000 83334 1' says: 500,000 entries, about 83,334 per distinct status (500,000 divided by our six statuses), and one entry per status and timestamp pair.

For real applications the ANALYZE documentation recommends: “Applications with short-lived database connections should run ‘PRAGMA optimize;’ once, just prior to closing each database connection.” Long-lived connections should run PRAGMA optimize=0x10002; when they open and PRAGMA optimize; periodically. The lesson for plan checking is just as important: a plan you inspect on an un-analyzed development database may differ from the plan production uses. The same page describes a way to capture statistics from a representative database and load them everywhere, which is how you make development, CI and production agree. The script ends with reset(conn), which clears the statistics again so later steps start clean.

Step 9: Turn plan reading into a gate

You can now read a plan. The next question is how to apply that skill automatically, for example to every query an agent wants to run. The idea of a plan gate is simple: ask SQLite for the plan, look for the bad shapes you just learned, and refuse the query (or ask a human) when you find one.

The EXPLAIN documentation warns that the output “is intended for interactive analysis and troubleshooting only” and that its details “are subject to change from one release of SQLite to the next,” and the EXPLAIN QUERY PLAN guide notes the format changed substantially in version 3.24.0 (2018-06-04) with further minor changes in 3.36.0 (2021-06-18). So this gate is a smoke alarm, not a contract. We take three precautions. It is conservative and reports what it did not understand. It pins the SQLite version it was tested on. And its test suite includes a canary test that fails loudly if a SQLite upgrade rewords the plan lines the gate depends on.

The gate

# plan_gate.py
"""plan_gate.py - reject a query whose EXPLAIN QUERY PLAN shows a big scan, a sort or an automatic index.

EXPLAIN QUERY PLAN output is meant for people and may change between SQLite releases, so this gate
is a smoke alarm: pin the SQLite version you tested and keep test_plan_gate.py in CI.
"""
import re
import sqlite3
import sys
from dataclasses import dataclass
from pathlib import Path

from common import plan_rows

TESTED_ON = "3.50"
SCAN = re.compile(r"^SCAN (?P<name>\S+)(?: USING (?:COVERING )?INDEX (?P<index>\S+))?$")
AUTOMATIC = re.compile(r"^SEARCH (?P<name>\S+) USING AUTOMATIC (?:COVERING )?INDEX")
SORT = re.compile(r"^USE TEMP B-TREE FOR (?:ORDER BY|GROUP BY|DISTINCT)")
FROM_JOIN = re.compile(r"\b(?:from|join)\s+([A-Za-z_]\w*)(?:\s+(?:as\s+)?([A-Za-z_]\w*))?", re.I)
NOT_ALIASES = {"where", "join", "left", "right", "inner", "outer", "cross", "natural", "full", "on", "using",
               "group", "order", "limit", "union", "except", "intersect", "having", "window", "indexed", "not"}
HINTS = {
    "full-table-scan": "no index serves this query: check functions on columns, leading wildcards and missing indexes",
    "index-scan": "reads every entry of an index: fine for ORDER BY ... LIMIT, wasteful as a filter",
    "temp-btree": "SQLite sorts rows in a temporary b-tree: an index that matches the ORDER BY avoids it",
    "automatic-index": "SQLite builds a throwaway index on every run: create a real index on the join column",
    "unresolved-scan": "could not map this name to a table (CTE, subquery or an alias the gate cannot parse)",
}


@dataclass(frozen=True)
class Finding:
    rule: str
    severity: str  # "fail" blocks the query, "warn" asks for a human look
    node: str
    hint: str


def real_tables(conn):
    rows = conn.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
    return {name.lower(): name for (name,) in rows}


def alias_map(sql, tables):
    """alias -> table for the common FROM/JOIN shapes (a regex, not a SQL parser)."""
    return {alias: table for table, alias in FROM_JOIN.findall(sql)
            if table.lower() in tables and alias and alias.lower() not in NOT_ALIASES}


def check(conn, sql, params=(), min_rows=10_000):
    """Return the findings for one SELECT. Tables with fewer than min_rows rows may be scanned freely."""
    if not sql.lstrip().lower().startswith(("select", "with")):
        raise ValueError("the plan gate only accepts SELECT statements")
    tables = real_tables(conn)
    aliases = alias_map(sql, tables)
    counts = {}

    def table_of(name):
        return tables.get(aliases.get(name, name).lower())

    def is_big(table):
        if table not in counts:
            counts[table] = conn.execute(f'SELECT count(*) FROM "{table}"').fetchone()[0]
        return counts[table] >= min_rows

    findings = []
    for _, _, detail in plan_rows(conn, sql, params):
        if match := AUTOMATIC.match(detail):
            table = table_of(match["name"])
            if table is None or is_big(table):
                findings.append(Finding("automatic-index", "fail", detail, HINTS["automatic-index"]))
        elif match := SCAN.match(detail):
            table = table_of(match["name"])
            if table is None:
                findings.append(Finding("unresolved-scan", "warn", detail, HINTS["unresolved-scan"]))
            elif is_big(table):
                rule, severity = ("index-scan", "warn") if match["index"] else ("full-table-scan", "fail")
                findings.append(Finding(rule, severity, detail, HINTS[rule]))
        elif SORT.match(detail):
            findings.append(Finding("temp-btree", "warn", detail, HINTS["temp-btree"]))
    return findings


def main(argv):
    strict = "--strict" in argv
    args = [a for a in argv if a != "--strict"]
    if len(args) != 2:
        print("usage: python plan_gate.py DATABASE \"SELECT ...\" [--strict]")
        return 2
    if not sqlite3.sqlite_version.startswith(TESTED_ON):
        print(f"note: tested on SQLite {TESTED_ON}.x, running {sqlite3.sqlite_version}; run test_plan_gate.py", file=sys.stderr)
    conn = sqlite3.connect(Path(args[0]).resolve().as_uri() + "?mode=ro", uri=True)
    conn.execute("PRAGMA query_only = ON")
    try:
        findings = check(conn, args[1])
    except (sqlite3.Error, ValueError) as exc:
        print(f"rejected: {exc}")
        return 2
    for f in findings:
        print(f"{f.severity.upper():4} {f.rule}: {f.node}\n     {f.hint}")
    if not findings:
        print("ok: no big scans, sorts or automatic indexes")
    return 1 if any(f.severity == "fail" for f in findings) or (strict and findings) else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Here is how it works, piece by piece.

The three regular expressions match the plan lines we care about: SCAN, SEARCH ... USING AUTOMATIC INDEX and USE TEMP B-TREE. An automatic index is one SQLite may build on the fly, lasting only for a single statement, when no suitable index exists. Seeing it means a missing index on a join column, and we confirm it in the tests with a self-join on the unindexed email column: SEARCH b USING AUTOMATIC COVERING INDEX (email=?).

Plans print the alias of a table, not its name: you saw SCAN o in the demo output below. alias_map therefore reads FROM and JOIN clauses with a regular expression to map aliases back to real tables. It is not a SQL parser and handles only the common shapes, so a name it cannot resolve becomes an unresolved-scan warning rather than a silent pass.

check only accepts statements starting with SELECT or WITH, counts the rows of each scanned table once, and ignores scans of tables smaller than min_rows (10,000 by default), because scanning a 20-row lookup table is fine. Then it applies the rules in this table.

Rule Severity Plan line it matches What it means
full-table-scan fail SCAN t on a big table No index serves the query. Fix the index or the filter.
index-scan warn SCAN t USING [COVERING] INDEX i Reads every index entry. Fine for ORDER BY ... LIMIT, wasteful as a filter.
temp-btree warn USE TEMP B-TREE FOR ... A sort or grouping without a matching index.
automatic-index fail SEARCH t USING AUTOMATIC INDEX SQLite is building a throwaway index on every run.
unresolved-scan warn SCAN x for an unknown name A CTE, a subquery or an alias the gate could not map.

Why is index-scan only a warning? Because of Step 5. The newest-20 query scans an index and is perfect, while a LIKE '%...' filter also scans an index and is wasteful. A plan alone cannot tell them apart, so the gate flags the shape and lets a human, or the step budget in Step 10, decide.

The command-line entry point opens the database read-only through a URI with mode=ro and also sets PRAGMA query_only = ON. It exits 0 for a clean or warn-only query, 1 when a rule fails (or for any finding with --strict), and 2 when the input was rejected. The SQLite documentation for URI filenames notes that “the database can be opened read-only by using ‘mode=ro’ as a query parameter,” and the pragma documentation says “The query_only pragma prevents data changes on database files when enabled” but also “However, the database is not truly read-only,” which is why we use both.

The tests

# test_plan_gate.py
"""test_plan_gate.py - run with: python -m pytest -q test_plan_gate.py"""
import random
import re
import sqlite3

import pytest

from build_db import FIRST, LAST, SCHEMA, STATUSES
from common import plan
from plan_gate import check

MIN = 1_000  # treat any table with 1,000+ rows as "big" in these tests


@pytest.fixture()
def db():
    conn = sqlite3.connect(":memory:")
    conn.executescript(SCHEMA)
    conn.execute("CREATE TABLE countries (code TEXT PRIMARY KEY, name TEXT)")
    conn.executemany("INSERT INTO countries VALUES (?, ?)", [("US", "United States"), ("DE", "Germany"), ("IN", "India")])
    rng = random.Random(5)
    conn.executemany(
        "INSERT INTO orders (customer_id, status, total_cents, created_at) VALUES (?, ?, ?, ?)",
        [(rng.randint(1, 3000), rng.choice(STATUSES), rng.randint(199, 9999),
          f"2026-{rng.randint(1, 9):02d}-{rng.randint(1, 28):02d} 10:00:00") for _ in range(20_000)],
    )
    conn.executemany(
        "INSERT INTO customers VALUES (?, ?, 'US', 'free', '2025-01-01 00:00:00')",
        [(i, f"{rng.choice(LAST)}.{rng.choice(FIRST)}{i}@example.com") for i in range(1, 3001)],
    )
    return conn


def rules(findings):
    return sorted(f.rule for f in findings)


def test_unindexed_filter_is_a_full_table_scan(db):
    found = check(db, "SELECT id FROM orders WHERE customer_id = ?", (7,), min_rows=MIN)
    assert rules(found) == ["full-table-scan"] and found[0].severity == "fail"


def test_indexed_filter_is_clean(db):
    db.execute("CREATE INDEX idx_customer ON orders(customer_id)")
    assert check(db, "SELECT id FROM orders WHERE customer_id = ?", (7,), min_rows=MIN) == []


def test_function_on_column_fails_until_the_expression_is_indexed(db):
    db.execute("CREATE INDEX idx_created ON orders(created_at)")
    sql = "SELECT id, total_cents FROM orders WHERE substr(created_at, 1, 10) = '2026-09-15'"
    assert rules(check(db, sql, min_rows=MIN)) == ["full-table-scan"]
    db.execute("CREATE INDEX idx_day ON orders(substr(created_at, 1, 10))")
    assert check(db, sql, min_rows=MIN) == []


def test_like_on_a_binary_index_warns_and_nocase_fixes_it(db):
    db.execute("CREATE INDEX idx_email ON customers(email)")
    sql = "SELECT id FROM customers WHERE email LIKE 'garcia.%'"
    assert rules(check(db, sql, min_rows=MIN)) == ["index-scan"]
    db.execute("DROP INDEX idx_email")
    db.execute("CREATE INDEX idx_email ON customers(email COLLATE NOCASE)")
    assert check(db, sql, min_rows=MIN) == []


def test_order_by_without_an_index_is_a_scan_plus_a_sort(db):
    found = check(db, "SELECT id FROM orders ORDER BY created_at DESC LIMIT 20", min_rows=MIN)
    assert rules(found) == ["full-table-scan", "temp-btree"]


def test_small_tables_may_be_scanned(db):
    assert check(db, "SELECT name FROM countries WHERE name = 'India'", min_rows=MIN) == []


def test_aliases_resolve_to_their_tables(db):
    found = check(db, "SELECT o.id FROM orders AS o WHERE o.customer_id = 7", min_rows=MIN)
    assert rules(found) == ["full-table-scan"] and found[0].node == "SCAN o"


def test_self_join_on_an_unindexed_column_builds_an_automatic_index(db):
    found = check(db, "SELECT a.id FROM customers a JOIN customers b ON b.email = a.email", min_rows=MIN)
    assert "automatic-index" in rules(found)


def test_statements_that_write_are_rejected_and_do_nothing(db):
    with pytest.raises(ValueError):
        check(db, "DELETE FROM orders")
    assert db.execute("SELECT count(*) FROM orders").fetchone()[0] == 20_000


def test_plan_wording_is_what_the_gate_parses(db):
    """If a SQLite upgrade rewords the plan, this test fails before the gate silently stops working."""
    assert plan(db, "SELECT id FROM orders WHERE customer_id = 1") == ["SCAN orders"]
    db.execute("CREATE INDEX idx_customer ON orders(customer_id)")
    assert re.fullmatch(r"SEARCH orders USING INDEX idx_customer \(customer_id=\?\)",
                        plan(db, "SELECT id, status FROM orders WHERE customer_id = 1")[0])
    assert plan(db, "SELECT id FROM orders ORDER BY total_cents")[-1] == "USE TEMP B-TREE FOR ORDER BY"
python -m pytest -q test_plan_gate.py
..........                                                               [100%]
10 passed in 0.40s

The ten tests each pin one behavior from the earlier steps: an unindexed filter fails and an indexed one is clean; a function on a column fails until the expression is indexed; LIKE on a BINARY index warns until the index is NOCASE; a sort without an index is reported; small tables may be scanned; aliases resolve to their tables; a self-join builds an automatic index; a DELETE is rejected and changes nothing; and the last one, test_plan_wording_is_what_the_gate_parses, is the canary. If a future SQLite rewords SCAN orders or USE TEMP B-TREE FOR ORDER BY, that test fails in CI before the gate quietly stops finding problems.

Run it on a production-like index set

Now we point the gate at shop.db with the kind of indexes a production copy might have. This small module defines them.

# prod_indexes.py
"""prod_indexes.py - the indexes a production copy of shop.db would have."""
from common import reset

PROD_INDEXES = [
    "CREATE INDEX idx_orders_customer_created ON orders(customer_id, created_at)",
    "CREATE INDEX idx_orders_status_created ON orders(status, created_at)",
    "CREATE INDEX idx_orders_created ON orders(created_at)",
    "CREATE INDEX idx_customers_email ON customers(email COLLATE NOCASE)",
]


def use_prod_indexes(conn):
    """Start from a clean slate, then create the production index set."""
    reset(conn)
    for ddl in PROD_INDEXES:
        conn.execute(ddl)
    conn.commit()
# step9_gate_demo.py
"""Step 9: run the plan gate against a production-like set of indexes."""
import subprocess
import sys

from common import connect
from prod_indexes import PROD_INDEXES, use_prod_indexes
from plan_gate import check

conn = connect()
use_prod_indexes(conn)
print("indexes:", ", ".join(d.split()[2] for d in PROD_INDEXES))
CASES = [
    ("customer's orders", "SELECT id, status FROM orders WHERE customer_id = 4242"),
    ("orders on one day (range)", "SELECT id, total_cents FROM orders WHERE created_at >= '2026-09-15' AND created_at < '2026-09-16'"),
    ("orders on one day (date())", "SELECT id, total_cents FROM orders WHERE date(created_at) = '2026-09-15'"),
    ("big orders", "SELECT id, total_cents FROM orders WHERE total_cents > 100000"),
    ("every delivered order", "SELECT id, total_cents FROM orders WHERE status = 'delivered'"),
    ("emails ending in a domain", "SELECT id FROM customers WHERE email LIKE '%@example.org'"),
    ("revenue per country", "SELECT c.country, SUM(o.total_cents) FROM orders o JOIN customers c ON c.id = o.customer_id GROUP BY c.country"),
    ("20 newest orders", "SELECT id FROM orders ORDER BY created_at DESC LIMIT 20"),
    ("a DELETE", "DELETE FROM orders WHERE status = 'cancelled'"),
]
for label, sql in CASES:
    try:
        findings = check(conn, sql)
    except ValueError as exc:
        print(f"REJECTED {label}: {exc}")
        continue
    verdict = "FAIL" if any(f.severity == "fail" for f in findings) else "WARN" if findings else "OK"
    print(f"{verdict:8} {label}")
    for f in findings:
        print(f"         {f.severity}: {f.rule} <- {f.node}")
conn.close()

print("--- the command line, with exit codes")
for sql in (CASES[0][1], CASES[3][1]):
    done = subprocess.run([sys.executable, "plan_gate.py", "shop.db", sql], capture_output=True, text=True)
    print(f"$ python plan_gate.py shop.db \"{sql}\"")
    print("   " + done.stdout.strip().replace("\n", "\n   "))
    print(f"   exit code {done.returncode}")
python step9_gate_demo.py
indexes: idx_orders_customer_created, idx_orders_status_created, idx_orders_created, idx_customers_email
OK       customer's orders
OK       orders on one day (range)
FAIL     orders on one day (date())
         fail: full-table-scan <- SCAN orders
FAIL     big orders
         fail: full-table-scan <- SCAN orders
OK       every delivered order
WARN     emails ending in a domain
         warn: index-scan <- SCAN customers USING COVERING INDEX idx_customers_email
FAIL     revenue per country
         fail: full-table-scan <- SCAN o
         warn: temp-btree <- USE TEMP B-TREE FOR GROUP BY
WARN     20 newest orders
         warn: index-scan <- SCAN orders USING COVERING INDEX idx_orders_created
REJECTED a DELETE: the plan gate only accepts SELECT statements

The first two OK lines are queries the indexes serve. The date() filter and the total_cents > 100000 filter fail with a full scan, the first because of the function on the column (Step 6a) and the second because nothing indexes total_cents. The LIKE '%@example.org' query warns that it scans an index. The revenue-per-country query fails on SCAN o (the alias of orders, which the gate resolved) and also warns about a temporary sort for GROUP BY; no index fixes a query that must read every order, so that one belongs on a replica or a summary table. The newest-20 query only warns, as designed, and the DELETE is rejected before any plan is requested.

Notice every delivered order: it is OK. Its plan is an index SEARCH, so the gate passes it, yet we saw in Step 7b that it touches 62% of the table. The gate reads the shape of the plan, not the size of the result. Step 10 closes that gap.

The rest of the output shows the command-line interface and its exit codes, which is what a CI job or an agent harness would check:

--- the command line, with exit codes
$ python plan_gate.py shop.db "SELECT id, status FROM orders WHERE customer_id = 4242"
   ok: no big scans, sorts or automatic indexes
   exit code 0
$ python plan_gate.py shop.db "SELECT id, total_cents FROM orders WHERE total_cents > 100000"
   FAIL full-table-scan: SCAN orders
        no index serves this query: check functions on columns, leading wildcards and missing indexes
   exit code 1

Step 10: Add a step budget for what a plan cannot see

A plan tells you how SQLite will search, not how much data it will touch. For that we use a second, dynamic layer: run the query under a budget and abort it when it runs too long or returns too much. Python’s sqlite3 module provides the hook. In the documentation’s words, set_progress_handler will “Register callable progress_handler to be invoked for every n instructions of the SQLite virtual machine,” and “Returning a non-zero value from the handler function will terminate the currently executing query and cause it to raise a DatabaseError exception.”

# budget.py
"""budget.py - stop a query that runs past a virtual-machine step budget or returns too many rows."""
import sqlite3


class BudgetExceeded(RuntimeError):
    def __init__(self, message, kind):
        super().__init__(message)
        self.kind = kind  # "steps" or "rows"


def run_with_budget(conn, sql, params=(), max_steps=1_000_000, max_rows=1_000):
    """Return (rows, steps) or raise BudgetExceeded. steps counts SQLite virtual-machine instructions."""
    steps = 0

    def tick():
        nonlocal steps
        steps += 1_000  # SQLite calls us once per 1,000 virtual-machine instructions
        return steps >= max_steps  # a true value makes SQLite abort the running statement

    conn.set_progress_handler(tick, 1_000)
    try:
        rows = conn.execute(sql, params).fetchmany(max_rows + 1)
    except sqlite3.OperationalError as exc:
        if "interrupted" in str(exc):
            raise BudgetExceeded(f"stopped after about {steps:,} steps", "steps") from exc
        raise
    finally:
        conn.set_progress_handler(None, 0)
    if len(rows) > max_rows:
        raise BudgetExceeded(f"more than {max_rows:,} rows", "rows")
    return rows, steps

The handler tick fires once per 1,000 virtual-machine instructions and adds 1,000 to a counter. Once the counter reaches max_steps it returns true, which makes SQLite abort the statement; we catch the resulting OperationalError and turn it into BudgetExceeded with kind steps. fetchmany(max_rows + 1) reads one more row than allowed, so the function can detect an oversized result without pulling a million rows into memory; that raises BudgetExceeded with kind rows. The finally block always removes the handler.

# step10_budget.py
"""Step 10: a step budget catches what a plan cannot see."""
import sqlite3
import time
from pathlib import Path

from budget import BudgetExceeded, run_with_budget
from common import connect
from prod_indexes import use_prod_indexes

setup = connect()
use_prod_indexes(setup)
setup.close()
conn = sqlite3.connect(Path("shop.db").resolve().as_uri() + "?mode=ro", uri=True)
conn.execute("PRAGMA query_only = ON")

CASES = [
    ("indexed lookup", "SELECT id, status FROM orders WHERE customer_id = 4242"),
    ("20 newest orders", "SELECT id FROM orders ORDER BY created_at DESC LIMIT 20"),
    ("date() filter, no usable index", "SELECT id, total_cents FROM orders WHERE date(created_at) = '2026-09-15'"),
    ("index SEARCH, but 62% of the table", "SELECT id, total_cents FROM orders WHERE status = 'delivered'"),
]
for label, sql in CASES:
    start = time.perf_counter()
    try:
        rows, steps = run_with_budget(conn, sql)
        outcome = f"{len(rows)} rows, " + (f"about {steps:,} steps" if steps else "under 1,000 steps")
    except BudgetExceeded as exc:
        outcome = f"BudgetExceeded: {exc}"
    print(f"{label:36} {outcome}  ({(time.perf_counter() - start) * 1000:.0f} ms)")

print("--- the connection is read-only")
try:
    conn.execute("DELETE FROM orders")
except sqlite3.OperationalError as exc:
    print("   ", exc)
python step10_budget.py
indexed lookup                       27 rows, under 1,000 steps  (0 ms)
20 newest orders                     20 rows, under 1,000 steps  (0 ms)
date() filter, no usable index       BudgetExceeded: stopped after about 1,000,000 steps  (23 ms)
index SEARCH, but 62% of the table   BudgetExceeded: more than 1,000 rows  (1 ms)
--- the connection is read-only
    attempt to write a readonly database

The indexed lookup and the newest-20 query finish in under 1,000 steps, so the counter never ticks (it reports 0). The date() filter has no usable index, so it exhausts the one-million-step budget and is stopped after 23 ms, instead of grinding on for the full scan. The last case is the one the gate passed: an index SEARCH returning 62% of the table is stopped by the row cap after one millisecond. The final lines show that the connection really is read-only: the DELETE fails with attempt to write a readonly database.

Common mistake

Do not pick the budget numbers from this article. One million steps and 1,000 rows suit this lab. Measure your own representative queries (the step count printed for a healthy query is a good starting point) and set the limits with headroom above them, or you will block legitimate queries.

Step 11: Try it on SQL written by AI models

Now the realistic test. I asked two small local models to write SQLite for eight plain-English questions about the shop database, giving each the table definitions and no other hint. The generator below is optional; it needs Ollama running. It uses temperature 0 and a fixed seed so each model gives a stable answer, and the answers are saved to ai_queries.json.

# gen_ai_queries.py
"""gen_ai_queries.py - ask local models to write SQL for plain-English questions; freeze the answers."""
import json
import re
import urllib.request

from build_db import SCHEMA

QUESTIONS = [
    "How many orders has customer 4242 placed?",
    "Show the 10 most recent orders.",
    "List the refunded orders placed in September 2026.",
    "Find all orders placed on 15 September 2026.",
    "Which customers have an email address at example.org?",
    "Which customers have an email address that starts with garcia?",
    "What is the total order value for each country?",
    "How many delivered orders were worth more than 1,000 dollars?",
]
MODELS = ["qwen2.5:1.5b", "qwen3.5:4b"]
PROMPT = "You write SQLite queries.\nSchema:\n{schema}\nAnswer with one SQLite SELECT statement and nothing else.\nQuestion: {question}"


def ask(model, question):
    body = {
        "model": model,
        "stream": False,
        "options": {"temperature": 0, "seed": 0},
        "messages": [{"role": "user", "content": PROMPT.format(schema=SCHEMA.strip(), question=question)}],
    }
    if model.startswith("qwen3"):
        body["think"] = False  # skip the hidden reasoning pass
    request = urllib.request.Request(
        "http://127.0.0.1:11434/api/chat", json.dumps(body).encode(), {"Content-Type": "application/json"}
    )
    with urllib.request.urlopen(request, timeout=600) as response:
        text = json.load(response)["message"]["content"]
    fenced = re.search(r"```(?:sql)?\s*(.*?)```", text, re.S | re.I)
    return " ".join((fenced.group(1) if fenced else text).split())


if __name__ == "__main__":
    answers = [{"model": m, "question": q, "sql": ask(m, q)} for m in MODELS for q in QUESTIONS]
    with open("ai_queries.json", "w", encoding="utf-8", newline="\n") as fh:
        json.dump(answers, fh, indent=1)
    for a in answers:
        print(f"[{a['model']}] {a['question']}\n    {a['sql']}")

Here are the frozen answers I got. If you skip the generator, save this as ai_queries.json and everything after it reproduces exactly.

[
 {
  "model": "qwen2.5:1.5b",
  "question": "How many orders has customer 4242 placed?",
  "sql": "SELECT COUNT(id) FROM orders WHERE customer_id = 4242;"
 },
 {
  "model": "qwen2.5:1.5b",
  "question": "Show the 10 most recent orders.",
  "sql": "SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;"
 },
 {
  "model": "qwen2.5:1.5b",
  "question": "List the refunded orders placed in September 2026.",
  "sql": "SELECT o.id, o.status, o.total_cents, o.created_at FROM orders o JOIN customers c ON o.customer_id = c.id WHERE strftime('%Y-%m', o.created_at) = '2026-09' AND o.status = 'refunded';"
 },
 {
  "model": "qwen2.5:1.5b",
  "question": "Find all orders placed on 15 September 2026.",
  "sql": "SELECT * FROM orders WHERE created_at = '2026-09-15'"
 },
 {
  "model": "qwen2.5:1.5b",
  "question": "Which customers have an email address at example.org?",
  "sql": "SELECT email FROM customers WHERE email LIKE '%example.org%'"
 },
 {
  "model": "qwen2.5:1.5b",
  "question": "Which customers have an email address that starts with garcia?",
  "sql": "SELECT email FROM customers WHERE email LIKE 'garcia%'"
 },
 {
  "model": "qwen2.5:1.5b",
  "question": "What is the total order value for each country?",
  "sql": "SELECT country, SUM(total_cents) FROM orders JOIN customers ON orders.customer_id = customers.id GROUP BY country;"
 },
 {
  "model": "qwen2.5:1.5b",
  "question": "How many delivered orders were worth more than 1,000 dollars?",
  "sql": "SELECT COUNT(id) FROM orders WHERE status = 'delivered' AND total_cents > 1000;"
 },
 {
  "model": "qwen3.5:4b",
  "question": "How many orders has customer 4242 placed?",
  "sql": "SELECT COUNT(*) FROM orders WHERE customer_id = 4242;"
 },
 {
  "model": "qwen3.5:4b",
  "question": "Show the 10 most recent orders.",
  "sql": "SELECT o.id, c.email, c.country, o.status, o.total_cents, o.created_at FROM orders o JOIN customers c ON o.customer_id = c.id ORDER BY o.created_at DESC LIMIT 10;"
 },
 {
  "model": "qwen3.5:4b",
  "question": "List the refunded orders placed in September 2026.",
  "sql": "SELECT o.id AS order_id, o.total_cents, c.email, c.country, c.tier FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.status = 'refunded' AND strftime('%Y-%m', o.created_at) = '2026-09';"
 },
 {
  "model": "qwen3.5:4b",
  "question": "Find all orders placed on 15 September 2026.",
  "sql": "SELECT * FROM orders WHERE date(created_at) = '2026-09-15';"
 },
 {
  "model": "qwen3.5:4b",
  "question": "Which customers have an email address at example.org?",
  "sql": "SELECT DISTINCT c.email FROM customers AS c;"
 },
 {
  "model": "qwen3.5:4b",
  "question": "Which customers have an email address that starts with garcia?",
  "sql": "SELECT * FROM customers WHERE email LIKE 'garcia%';"
 },
 {
  "model": "qwen3.5:4b",
  "question": "What is the total order value for each country?",
  "sql": "SELECT c.country, SUM(o.total_cents) AS total_order_value FROM customers c JOIN orders o ON c.id = o.customer_id GROUP BY c.country;"
 },
 {
  "model": "qwen3.5:4b",
  "question": "How many delivered orders were worth more than 1,000 dollars?",
  "sql": "SELECT COUNT(*) FROM orders WHERE status = 'delivered' AND total_cents > 100000;"
 }
]

Read a few of them before running anything. Both models wrote valid SQL for every question. But look at question 4 for qwen2.5:1.5b: created_at = '2026-09-15' compares a timestamp column, which includes the time of day, with a bare date. And for question 8 it wrote total_cents > 1000 for “more than 1,000 dollars,” forgetting that the column is in cents. Meanwhile qwen3.5:4b answered question 5 with SELECT DISTINCT c.email FROM customers AS c;, which has no filter at all.

The next script runs every query through the gate and the budget, and also against an answer key, one trusted query per question that I wrote by hand, so we can tell whether each answer is right as well as cheap.

# step11_ai_gate.py
"""Step 11: run frozen, model-written SQL through the plan gate, the step budget and an answer key."""
import json
import sqlite3
from pathlib import Path

from budget import BudgetExceeded, run_with_budget
from common import connect
from prod_indexes import use_prod_indexes
from plan_gate import check

ANSWER_KEY = [  # one trusted query per question, in the same order as gen_ai_queries.QUESTIONS
    "SELECT count(*) FROM orders WHERE customer_id = 4242",
    "SELECT id FROM orders ORDER BY created_at DESC LIMIT 10",
    "SELECT id FROM orders WHERE status = 'refunded' AND created_at >= '2026-09-01' AND created_at < '2026-10-01'",
    "SELECT id FROM orders WHERE created_at >= '2026-09-15' AND created_at < '2026-09-16'",
    "SELECT id FROM customers WHERE email LIKE '%@example.org'",
    "SELECT id FROM customers WHERE email LIKE 'garcia%'",
    "SELECT c.country, SUM(o.total_cents) FROM orders o JOIN customers c ON c.id = o.customer_id GROUP BY c.country",
    "SELECT count(*) FROM orders WHERE status = 'delivered' AND total_cents > 100000",
]

setup = connect()
use_prod_indexes(setup)
setup.close()
conn = sqlite3.connect(Path("shop.db").resolve().as_uri() + "?mode=ro", uri=True)
conn.execute("PRAGMA query_only = ON")


def shape(rows):
    """A single number is compared by value; anything else by row count."""
    return rows[0][0] if len(rows) == 1 and len(rows[0]) == 1 else len(rows)


tally = {}
for i, item in enumerate(json.load(open("ai_queries.json", encoding="utf-8"))):
    n = i % len(ANSWER_KEY)
    try:
        findings = check(conn, item["sql"])
        gate = "FAIL" if any(f.severity == "fail" for f in findings) else "WARN" if findings else "OK"
    except (sqlite3.Error, ValueError):
        gate = "ERROR"
    try:
        run_with_budget(conn, item["sql"])
        budget = "ok"
    except BudgetExceeded as exc:
        budget = f"STOP:{exc.kind}"
    expected = shape(conn.execute(ANSWER_KEY[n]).fetchall())
    actual = shape(conn.execute(item["sql"]).fetchall())
    right = actual == expected
    print(f"{item['model']:13} Q{n + 1}  gate {gate:5}  budget {budget:10}  " + ("answer ok" if right else f"WRONG ANSWER ({actual} vs {expected})"))
    row = tally.setdefault(item["model"], {"gate": [], "stopped": 0, "wrong": 0, "slipped": []})
    row["gate"].append(gate)
    row["stopped"] += budget != "ok"
    row["wrong"] += not right
    if not right and gate in ("OK", "WARN") and budget == "ok":
        row["slipped"].append(f"Q{n + 1}")

print("--- summary")
for model, row in tally.items():
    gates = {g: row["gate"].count(g) for g in ("OK", "WARN", "FAIL", "ERROR")}
    print(f"{model:13} gate {gates}  budget stops {row['stopped']}  wrong answers {row['wrong']}  wrong but past both layers: {row['slipped'] or 'none'}")
python step11_ai_gate.py
qwen2.5:1.5b  Q1  gate OK     budget ok          answer ok
qwen2.5:1.5b  Q2  gate WARN   budget ok          answer ok
qwen2.5:1.5b  Q3  gate OK     budget ok          answer ok
qwen2.5:1.5b  Q4  gate OK     budget ok          WRONG ANSWER (0 vs 770)
qwen2.5:1.5b  Q5  gate WARN   budget STOP:rows   answer ok
qwen2.5:1.5b  Q6  gate OK     budget STOP:rows   answer ok
qwen2.5:1.5b  Q7  gate FAIL   budget STOP:steps  answer ok
qwen2.5:1.5b  Q8  gate OK     budget STOP:steps  WRONG ANSWER (275897 vs 14)
qwen3.5:4b    Q1  gate OK     budget ok          answer ok
qwen3.5:4b    Q2  gate WARN   budget ok          answer ok
qwen3.5:4b    Q3  gate OK     budget ok          answer ok
qwen3.5:4b    Q4  gate FAIL   budget STOP:steps  answer ok
qwen3.5:4b    Q5  gate WARN   budget STOP:rows   WRONG ANSWER (20000 vs 6622)
qwen3.5:4b    Q6  gate OK     budget STOP:rows   answer ok
qwen3.5:4b    Q7  gate FAIL   budget STOP:steps  answer ok
qwen3.5:4b    Q8  gate OK     budget STOP:steps  answer ok
--- summary
qwen2.5:1.5b  gate {'OK': 5, 'WARN': 2, 'FAIL': 1, 'ERROR': 0}  budget stops 4  wrong answers 2  wrong but past both layers: ['Q4']
qwen3.5:4b    gate {'OK': 4, 'WARN': 2, 'FAIL': 2, 'ERROR': 0}  budget stops 5  wrong answers 1  wrong but past both layers: none

What each layer caught

The gate failed the revenue-per-country query from both models (a real full scan that no index can fix) and, for qwen3.5:4b, the date(created_at) filter in question 4. It warned on the newest-orders and email queries, as designed.

The budget caught what the gate let through. Both models’ question 8 passes the gate, because status = 'delivered' is a clean index SEARCH, yet the filter on total_cents forces SQLite to visit about 310,000 table rows, so the step budget stops it. Questions 5 and 6 are stopped by the row cap, because they return thousands of rows. For most of those runs that is the correct answer, so whether it is acceptable is a policy decision for you, not a bug in the query.

Neither layer caught the bare-date comparison in qwen2.5:1.5b’s question 4. Checking that query separately, its plan is SEARCH orders USING INDEX idx_orders_created (created_at=?), it runs in about 0.03 ms, and it returns 0 rows instead of 770. That is the most important result in this tutorial: a plan gate measures cost, not correctness. A query can be fast and wrong. The cents-versus-dollars slip in question 8 was only stopped incidentally, because the wrong threshold made the query expensive; the answer key shows it is wrong too (275,897 instead of 14). The DISTINCT c.email query was flagged by both layers and is also wrong (20,000 rows instead of 6,622), but the only check that proves it wrong is the answer key.

Treat these numbers as an illustration, not a benchmark. They come from two small local models, eight questions and a single deterministic run each. A different model, prompt or Ollama version would produce different mistakes. What generalizes is the layering: use the gate to stop bad plans, the budget to stop expensive ones, and tests or review to check meaning. If you want to build that third layer for generated code, our guide to reviewing AI-generated Python code and the guardrails for AI-generated Terraform tutorial apply the same gate-before-run idea to other kinds of output.

Common mistakes and gotchas

Judging a plan on an empty development database

Plans depend on table sizes and statistics. A table scan of 200 rows is harmless, so a bad plan can hide for months. Step 8 showed a plan flip from a 23 ms scan to a 0.2 ms skip-scan without any schema change. Check plans against production-sized data, and make sure statistics match (run ANALYZE or PRAGMA optimize, or load saved statistics in CI).

Believing SEARCH means fast

SEARCH means an index is used. Step 7b was 20% slower than a scan because the query returned 62% of the table, and Step 11’s question 8 passed the gate while visiting hundreds of thousands of rows. Look at how many rows the query will touch, not only at the first word of the plan.

Adding an index for every query

Five indexes made inserts more than seven times slower in Step 7a and a full index was 23 times bigger than a partial one in 7c. Add an index when a measured plan and a real query justify it, and prefer one multi-column index shaped like your queries over several single-column ones.

Parsing EXPLAIN QUERY PLAN in production code

The SQLite authors say not to. If you build a gate anyway, pin the version, keep a canary test on the wording, treat anything unrecognized as a warning, and rerun the tests whenever Python or SQLite is upgraded.

Forgetting that LIKE is special

A prefix LIKE on a default index silently scans (Step 6b). Declare the index COLLATE NOCASE, use GLOB, or write the range yourself. Remember that a leading wildcard can never use an index, and that SQLite’s case-insensitivity only covers ASCII letters.

Verify the whole thing end to end

Run every script in order from a clean folder. The first command rebuilds the database; the last one runs the gate’s tests. On PowerShell use the first form and on macOS or Linux the second.

foreach ($f in "build_db","step2_first_plan","step3_composite","step4_covering","step5_order_by","step6_ignored","step7_costs","step8_analyze") { python "$f.py" }
python -m pytest -q test_plan_gate.py
foreach ($f in "step9_gate_demo","step10_budget","step11_ai_gate") { python "$f.py" }
for f in build_db step2_first_plan step3_composite step4_covering step5_order_by step6_ignored step7_costs step8_analyze; do python $f.py; done
python -m pytest -q test_plan_gate.py
for f in step9_gate_demo step10_budget step11_ai_gate; do python $f.py; done

You are done when the row counts are 20,000 and 500,000, every plan line matches what you saw above, pytest reports 10 passed, the Step 9 table shows the same OK, WARN, FAIL and REJECTED verdicts, and Step 11 ends with a summary line per model. Expect the timings to differ by machine and by run. The ratios (a scan is hundreds of times slower than an index search, five indexes slow inserts several times over) should hold.

Where to go next

You can now read an EXPLAIN QUERY PLAN, choose index column order deliberately, recognize why a plan ignores an index, and apply those skills automatically with a gate and a budget. To keep learning:

  • Read SQLite’s query planning guide and optimizer overview end to end. The first is gentle and illustrated, the second is the reference for every rule used here.
  • Load statistics from a representative database into your CI database, as the ANALYZE page describes, so the gate sees production plans.
  • Pair the gate with parameterized queries. A gate checks performance; it does not stop injection or prove the query means what the user asked.
  • Measure real query latency in production with OpenTelemetry slow-query metrics, which is the runtime counterpart to this tutorial’s static check.
  • SQLite appears across our other Python tutorials, including a write-ahead log built from scratch and hybrid search with SQLite FTS5. Run the plan gate over their queries as an exercise.
  • Most SQL databases have their own EXPLAIN command with different output. The habit transfers even though the wording does not.

Sources and documentation

  • SQLite: EXPLAIN QUERY PLAN and EXPLAIN
  • SQLite: Query Planning and The SQLite Query Optimizer Overview
  • Indexes on expressions, Partial indexes, ANALYZE, Database file format (sqlite_stat1), PRAGMA statements and URI filenames
  • Python: sqlite3, DB-API 2.0 interface for SQLite databases

Tags:

AI Coding AgentspytestPythonQuery OptimizationSQLSQLite

Share

Classification yard at Godorf in Cologne, Germany, with dozens of numbered tracks fanning out and rejoining through switches beside an industrial plant
Previous Post

Cloudflare’s Artifacts Turns Agent Git Into a Repo-Per-Task Bet and Leaves Merging to a Contest

A row of roadside mailboxes on posts beside a desert road, with a red STOP sign at the left edge
Next Post

Attackers Are Exploiting an Unpatched FortiMail Flaw, and CISA Wants Agencies to Act by October 4

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
05 Oct
How to Use frozendict in Python 3.15 to Freeze Config and Cache Dictionary Arguments
05 Oct
Kubernetes Node Swap Turns Idle Agent Memory Into a Density Bet With No Wake-Up Test
Trending
October 5, 2026
How to Use frozendict in Python 3.15 to Freeze Config and Cache Dictionary Arguments
October 5, 2026
Kubernetes Node Swap Turns Idle Agent Memory Into a Density Bet With No Wake-Up Test
October 5, 2026
Denmark Says 8.8 Million Population Register Records Were Pulled Through One Company’s Lawful Access
October 5, 2026
How to Prepare Your Python Code for the Python 3.15 UTF-8 Default and Fix Windows Encoding Bugs
October 5, 2026
BT’s TalkTalk Rescue Turns Telecom Continuity Into a New Merger-Control Ground
October 5, 2026
Google Stops Accepting Product Bug Reports for Its Open-Source Bounty, Citing Automated Submissions

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026