SQLite Operations
SQLite is one engine with many hosts. The SQL semantics, query planner, and pragmas are
the same whether you reach it through the sqlite3 CLI, Python, node:sqlite,
better-sqlite3, Bun, Cloudflare D1, or libSQL/Turso — what differs is the driver surface
and the operational envelope (who owns the file, what a "connection" costs, whether you
can even run PRAGMA). Reason about the engine first; then check the host section for the
traps that differ.
Where does the problem live?
│
├─ A statement is slow, or scans too much
│ └─ EXPLAIN QUERY PLAN first, always → references/query-performance.md
│
├─ "database is locked" / SQLITE_BUSY / writers blocking readers
│ └─ WAL + busy_timeout + BEGIN IMMEDIATE → references/concurrency-durability.md
│
├─ Wrong data got in, or a constraint didn't fire
│ └─ Type affinity, STRICT, foreign_keys=OFF → references/schema-design.md
│
├─ Search / JSON / geo / analytics feature question
│ └─ FTS5, JSON, R-tree, window fns → references/feature-modules.md
│
├─ Running on a managed/edge engine (D1, Turso)
│ └─ references/d1-edge.md + references/hosts.md
│
└─ Corruption, size, backup, VACUUM
└─ references/operations.md
Measurement discipline (read this before optimising anything)
Most SQLite "optimisations" are unmeasured. Four rules, in order of how often they are
broken:
- Measure the statement, not the tool call. An expensive aggregate that ships inside
a batch another query was already sending costs no extra round trip and is therefore
invisible to per-call timing — while still scanning the whole table on every request.
Decompose multi-part statements and time each part separately.
- Report latency AND rows scanned. They move independently. An optimisation can cut
latency ~25x while leaving rows-read essentially unchanged (and on a billed engine like
D1, rows read is the money metric — see
references/d1-edge.md).
- Never trust wall-clock time from a CLI. Process startup dominates. Use the engine's
own reported duration (
.timer on in the CLI, meta.timings.sql_duration_ms on D1).
- Take a median of 10+ runs and report the range. First runs are cold. In one measured
session a cold run hit 2,495 ms against a 171 ms median on the same statement — a
1.5–1.7x first-run penalty was routine on multi-thousand-row reads.
# sqlite3 CLI: engine-reported timing, not shell time
sqlite3 app.db '.timer on' "SELECT count(*) FROM q_product WHERE org LIKE '%acme%';"
# What the planner thinks the data looks like (empty = ANALYZE never ran)
sqlite3 app.db 'SELECT * FROM sqlite_stat1;'
Prove an index will help before you create it
The highest-leverage trick in this skill, and the one that keeps schema work inside a
deploy gate: run the identical statement shape against a column an existing index
already covers. Same table, same row count, same predicate shape — only the column
changes. The difference is your projected payoff, measured on live production data with
zero schema writes.
-- Hypothesis: a covering index on (org, product_id) makes this fast.
-- Unindexed control (what you have today):
SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
-- Proof shot: same shape, over a column an existing index already covers.
-- If this is fast, the index is worth writing. If it isn't, the index is not your problem.
SELECT DISTINCT org FROM q_product WHERE org LIKE '%acme%';
In the worked example below the proof shot returned 6.75 ms against a 171.83 ms control —
enough to justify the index without touching production schema.
EXPLAIN QUERY PLAN — the 60-second read
EXPLAIN QUERY PLAN (EQP) is the first command for any slow statement. It is cheap, safe,
read-only, and available on every host that lets you run arbitrary SQL.
EXPLAIN QUERY PLAN
SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';
| Plan line |
Means |
Verdict |
SEARCH t USING INDEX ix (col=?) |
B-tree seek, touches matching rows only |
Best case |
SEARCH t USING COVERING INDEX ix |
Seek, and every needed column is in the index — table never read |
Best case |
SCAN t USING COVERING INDEX ix |
Full pass, but over narrow index entries, not wide rows |
Often fine — see below |
SCAN t USING INDEX ix |
Full pass over the index and a row fetch per hit |
Suspicious: the index is buying little |
SCAN t |
Full table scan |
Fix it, unless the table is tiny |
USE TEMP B-TREE FOR ORDER BY |
Sorting because no index supplies the order |
Cost signal |
USE TEMP B-TREE FOR GROUP BY |
Same, for grouping |
Cost signal |
CORRELATED SCALAR SUBQUERY |
Subquery re-executed per outer row |
Usually the whole problem |
The distinction that matters most: SCAN … USING COVERING INDEX is not a failure.
A covering scan reads narrow index entries instead of paging in wide rows, which is exactly
how you make an unseekable predicate fast.
Deep dive: ./references/query-performance.md — index design, column order, partial and
expression indexes, ANALYZE/sqlite_stat1, and the full catalogue of planner defeats.
The unseekable-predicate trap (worked example)
A leading-wildcard LIKE '%x%' can never use a B-tree — SQLite optimises LIKE only
for an anchored prefix ('x%'). So a plain index on that column changes nothing, people
observe no improvement, and conclude "indexing didn't help here". The index wasn't wrong;
the shape was. The fix is to make the scan covering, so the unavoidable full pass
reads narrow index entries instead of wide rows.
-- Column order is load-bearing: FILTERED column first, PROJECTED column second.
CREATE INDEX q_product_org_product ON q_product(org, product_id);
Worked example — one database, not a constant. Measured 2026-08-04 against a live
Cloudflare D1 (atdw-mirror, region OC, colo SYD), 12 runs each, median of server-side
sql_duration_ms; 73-column table, 58k rows.
Before: SCAN q_product USING INDEX q_product_org, 171.83 ms, 60,736 rows read.
The identical statement shape over an already-covered column: 6.75 ms, 58,433 rows
read. ~25x faster with rows-read essentially unchanged — proof that the win came from
row width, not from touching fewer rows. Your table's numbers will differ; the shape of
the result is what transfers.
Two further findings from the same session worth internalising:
- Once the covering index existed, SQLite dropped the
GROUP BY temp B-tree by itself.
A hand-rewrite to avoid the grouping measured 5.99 ms vs 5.85 ms — noise. Don't
hand-optimise around a temp B-tree until you have re-read the plan post-index.
- An unindexed
MAX() riding inside a batch another query was already sending cost
28.09 ms and 58,432 rows scanned on every response across four tools, while the
statement without it cost 0.17 ms / 2 rows. The same MAX() over an indexed column:
0.17 ms / 1 row. It never showed up in per-query timing because it added no round trip.
Verify the planner's choice with and without statistics
A covering index may only be chosen once ANALYZE has populated sqlite_stat1 — and
many hosted engines never run ANALYZE for you. Test both states before you rely on it:
ANALYZE; -- populate sqlite_stat1
EXPLAIN QUERY PLAN SELECT ...; -- record the plan
DELETE FROM sqlite_stat1; -- simulate a never-analyzed database
ANALYZE sqlite_master; -- force the planner to reload (now-empty) stats
EXPLAIN QUERY PLAN SELECT ...; -- same plan? then you are safe either way
In the worked example the covering index was chosen in both states — verified, not
assumed. Do the same check rather than inheriting that result.
Index design in one table
| Predicate shape |
Indexable? |
What to build |
col = ?, col IN (…), col > ?, BETWEEN |
Yes |
B-tree on col |
a = ? AND b = ? |
Yes |
Composite (a, b) — equality columns first |
a = ? ORDER BY b |
Yes |
Composite (a, b) — kills the temp B-tree |
col LIKE 'x%' (anchored) |
Yes, if col is TEXT with BINARY collation |
B-tree on col |
col LIKE '%x%' (leading wildcard) |
No seek possible |
Make the scan covering, or use FTS5 trigram |
lower(col) = ? |
Not on a plain index |
Expression index ON t(lower(col)) |
status = 'open' where 2% of rows qualify |
Yes |
Partial index WHERE status = 'open' |
json_extract(doc,'$.k') = ? |
Not on a plain index |
Expression index, or generated column + index |
Rules that repay themselves: put the filtered column first and the projected
column second in a covering index; index the column, never a function of it (unless it is
an expression index); and every index you add taxes every write — audit before adding.
Concurrency and durability — the 80/20
| Symptom |
Cause |
Fix |
SQLITE_BUSY |
Another connection holds a lock; yours gave up waiting |
PRAGMA busy_timeout = 5000; and keep write transactions short |
SQLITE_LOCKED |
Conflict within the same connection (or a shared cache) |
Fix the code — a retry loop will spin forever |
| "database is locked" mid-transaction |
BEGIN (DEFERRED) read that later writes → upgrade deadlock, not retryable |
BEGIN IMMEDIATE for any transaction that will write |
| Readers blocked by a writer |
Rollback journal mode |
PRAGMA journal_mode = WAL; (persistent, set once) |
-wal file grows without bound |
Long-lived reader pins the checkpoint |
Close/refresh readers; PRAGMA wal_checkpoint(TRUNCATE); |
PRAGMA journal_mode = WAL; -- persistent; survives reconnect
PRAGMA busy_timeout = 5000; -- per-connection; set on EVERY connection
PRAGMA foreign_keys = ON; -- per-connection, OFF by default — see below
PRAGMA synchronous = NORMAL; -- safe with WAL; FULL only if you fear power loss
Deep dive: ./references/concurrency-durability.md — WAL internals, the
DEFERRED-upgrade deadlock, synchronous levels, checkpoint starvation, multi-process access.
Schema — the three silent bugs
PRAGMA foreign_keys is OFF by default. Per connection, every connection. Your
REFERENCES clauses parse, are stored, and do nothing. This is the classic silent
data-integrity bug in SQLite applications.
- Type affinity is not a type. A
TEXT column will happily store an integer; a
declared type is a suggestion about conversion. Use STRICT tables (SQLite 3.37+)
when you want a declared type enforced.
ALTER TABLE is limited. Adding a column and renaming are supported; dropping,
retyping, and changing constraints need the 12-step recreate dance.
CREATE TABLE product (
id INTEGER PRIMARY KEY,
org TEXT NOT NULL,
price REAL NOT NULL,
doc TEXT,
-- indexable projection of a JSON field
sku TEXT GENERATED ALWAYS AS (json_extract(doc, '$.sku')) VIRTUAL
) STRICT;
Deep dive: ./references/schema-design.md (affinity, STRICT, generated columns,
WITHOUT ROWID, constraints) and ./references/migration-patterns.md (the 12-step ALTER
dance, versioned migration runners).
Feature modules at a glance
| Need |
Reach for |
Note |
| Substring / fuzzy text search |
FTS5 with the trigram tokenizer |
The real answer to LIKE '%x%' at scale |
| Word/phrase search with ranking |
FTS5 + bm25() |
External-content table avoids duplicating the corpus |
| Semi-structured documents |
json_extract / -> / ->>, JSONB (3.45+) |
Index via generated column or expression index |
| Bounding-box / interval overlap |
R-tree virtual table |
Compile-time module; check availability |
| Running totals, ranking, gaps |
Window functions (3.25+) |
Same syntax as PostgreSQL |
| Insert-or-update |
ON CONFLICT … DO UPDATE (3.24+) |
excluded.col refers to the proposed row |
| Read back what you wrote |
RETURNING (3.35+) |
Makes atomic claim-a-job patterns single-statement |
Deep dive: ./references/feature-modules.md.
Hosts
The engine is the same; the envelope is not.
| Host |
Connection model |
Watch out for |
sqlite3 CLI |
Direct file |
.timer on for real timings; .mode/.headers for output |
Python sqlite3 |
Direct file, per-connection pragmas |
Implicit transaction handling; check_same_thread |
Python aiosqlite |
Thread-backed async wrapper |
Still one writer; see ./references/async-patterns.md |
node:sqlite |
Synchronous, built into Node |
No external dependency; API still stabilising |
better-sqlite3 |
Synchronous, native addon |
Fastest Node option; prepared statements are the unit of reuse |
bun:sqlite |
Synchronous, built into Bun |
API close to better-sqlite3, not identical |
| Cloudflare D1 |
HTTP/RPC to a managed SQLite |
Billed on rows read; 100-parameter cap; no PRAGMA surface |
| libSQL / Turso |
Server or embedded replica |
Replica staleness; syntax extensions beyond stock SQLite |
Deep dive: ./references/hosts.md for per-host connection recipes and traps.
On D1 specifically, three platform features have no stock-SQLite equivalent and are the most
commonly missed:
wrangler d1 insights <db> --sort-type=sum --sort-by=reads --limit=10 # rank REAL queries by cost
wrangler d1 time-travel info <db> # 30-day point-in-time restore point
# Sessions API (env.DB.withSession(bookmark)) - read replicas, sequential consistency
./references/d1-edge.md covers those plus the rows-read economics, the verified limits
table, the error catalogue, and import/export. For the production incident patterns —
a timed-out migrations apply --remote that landed anyway, batch() treating a 0-row
scoped UPDATE as success, and the opt-in-to-replica rollout shape for read replication —
see ./references/d1-production-patterns.md.
Operations
sqlite3 app.db 'PRAGMA quick_check;' # fast structural check
sqlite3 app.db 'PRAGMA integrity_check;' # full check — slow on big DBs
sqlite3 app.db "VACUUM INTO 'backup.db';" # consistent backup, no downtime, defragmented
sqlite3 app.db '.dump' > backup.sql # portable text backup
sqlite3 app.db 'PRAGMA optimize;' # run before closing a long-lived connection
Never copy a live database file with cp while a writer is active — use
VACUUM INTO, the backup API, or .dump.
Deep dive: ./references/operations.md — corruption causes and recovery, VACUUM vs
VACUUM INTO, page/cache sizing, size analysis.
Triage script
scripts/eqp-triage.py reads an EXPLAIN QUERY PLAN result — either by running the
statement against a database, or from piped plan text — and classifies each line by
severity with a fix hint. Exits 10 when it finds something (the domain signal), 0
when the plan is clean.
# Run against a database file (uses Python's bundled sqlite3 — no external binary needed)
python3 scripts/eqp-triage.py --db app.db \
--sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'"
# Triage a plan captured elsewhere (D1, a log, a colleague's paste)
wrangler d1 execute atdw-mirror --remote --json \
--command "EXPLAIN QUERY PLAN SELECT product_id FROM q_product WHERE org LIKE '%acme%'" \
| python3 scripts/eqp-triage.py
# Machine-readable findings
python3 scripts/eqp-triage.py --db app.db --sql "SELECT ..." --json | jq '.data[]'
Gotchas
| Mistake |
Why it bites |
Fix |
Adding an index for LIKE '%x%' |
Leading wildcard can never seek |
Covering index, or FTS5 trigram |
| Timing with a shell stopwatch |
CLI/driver startup dominates |
Engine-reported duration; median of 10+ |
| Timing the tool call, not the statement |
Piggy-backed statements are invisible |
Decompose and time each part |
Assuming REFERENCES is enforced |
foreign_keys is OFF per connection |
PRAGMA foreign_keys = ON on every connection |
| Assuming a declared type is enforced |
Affinity, not typing |
STRICT tables |
Retrying SQLITE_LOCKED |
Same-connection conflict never clears |
Fix the code path |
BEGIN then write |
DEFERRED→write upgrade deadlocks and is not retryable |
BEGIN IMMEDIATE |
cp on a live database |
Torn copy |
VACUUM INTO / backup API |
SELECT * |
Defeats covering indexes; widens every row read |
Project only what you need |
VACUUM to "speed things up" |
Rewrites the whole file, needs 2x space, holds a lock |
PRAGMA optimize / targeted index work |
| Trusting one cold run |
1.5–1.7x first-run penalty is routine |
Median of 10+, report the range |
| Inlining literals to dodge a parameter cap |
That is how injection happens |
Chunk the work; keep bound parameters |
| Re-running a timed-out remote migration |
The apply may have landed; the error was about the response |
Verify schema state read-only first — ./references/d1-production-patterns.md |
Reading a committed batch() as per-statement success |
A conditional UPDATE matching 0 rows is not an error |
Check meta.changes; 0 on a scoped write = 403/conflict |
Reference files
| Reference |
Load when |
./references/query-performance.md |
Any slow statement: EQP, index design, ANALYZE, planner defeats, measurement method |
./references/d1-edge.md |
Cloudflare D1: rows-read economics, d1 insights, Sessions API/replication, Time Travel, limits, errors |
./references/d1-production-patterns.md |
Running D1 in production: verifying a timed-out migration, batch() 0-row write verification, the opt-in-to-replica replication rollout |
./references/concurrency-durability.md |
Locking, WAL, busy_timeout, transaction modes, checkpointing, durability |
./references/schema-design.md |
Affinity, STRICT, foreign keys, generated columns, WITHOUT ROWID, constraints |
./references/schema-patterns.md |
Ready-made table designs: state, cache, event log, queue, session, dedup |
./references/migration-patterns.md |
Versioned migrations, the 12-step ALTER dance, host-specific runners |
./references/feature-modules.md |
FTS5, JSON/JSONB, R-tree, window functions, upsert, RETURNING |
./references/hosts.md |
Per-host connection recipes and driver traps (Python, Node, Bun, D1, libSQL) |
./references/async-patterns.md |
Python aiosqlite depth: async CRUD, batching, pooling |
./references/operations.md |
Integrity checks, corruption recovery, VACUUM, backups, size and page tuning |
./references/testing.md |
In-memory vs file databases, fixtures, deterministic seeding, migration tests |
See also
| Skill |
When to combine |
sql-ops |
Vendor-neutral SQL: CTEs, window functions, JOIN strategy |
perf-ops |
The wider performance workflow — profiling, load testing, before/after protocol |
cloudflare-ops |
Workers, bindings, and deployment around a D1 database |
postgres-ops |
When the workload has outgrown SQLite's single-writer model |
python-database-ops |
SQLAlchemy / ORM layers over SQLite |
1---2name: sqlite-ops3description: SQLite across every host and engine - query performance, concurrency, schema, feature modules, operations. Triggers on: sqlite, slow query, EXPLAIN QUERY PLAN, query plan, SCAN vs SEARCH, covering index, index not used, rows read, rows_read, sql_duration_ms, ANALYZE, sqlite_stat1, LIKE performance, database is locked, SQLITE_BUSY, WAL, busy_timeout, STRICT tables, type affinity, foreign_keys, VACUUM, integrity_check, fts5, trigram, json_extract, D1, cloudflare d1, wrangler d1, node:sqlite, better-sqlite3, bun:sqlite, aiosqlite, libsql, turso, migration, d1 batch, read replication, sessions api, d1 bookmark, migration timeout.4license: MIT5---67# SQLite Operations89SQLite is one engine with many hosts. The **SQL semantics, query planner, and pragmas are10the same** whether you reach it through the `sqlite3` CLI, Python, `node:sqlite`,11better-sqlite3, Bun, Cloudflare D1, or libSQL/Turso — what differs is the *driver surface*12and the *operational envelope* (who owns the file, what a "connection" costs, whether you13can even run `PRAGMA`). Reason about the engine first; then check the host section for the14traps that differ.1516```17Where does the problem live?18│19├─ A statement is slow, or scans too much20│ └─ EXPLAIN QUERY PLAN first, always → references/query-performance.md21│22├─ "database is locked" / SQLITE_BUSY / writers blocking readers23│ └─ WAL + busy_timeout + BEGIN IMMEDIATE → references/concurrency-durability.md24│25├─ Wrong data got in, or a constraint didn't fire26│ └─ Type affinity, STRICT, foreign_keys=OFF → references/schema-design.md27│28├─ Search / JSON / geo / analytics feature question29│ └─ FTS5, JSON, R-tree, window fns → references/feature-modules.md30│31├─ Running on a managed/edge engine (D1, Turso)32│ └─ references/d1-edge.md + references/hosts.md33│34└─ Corruption, size, backup, VACUUM35 └─ references/operations.md36```3738## Measurement discipline (read this before optimising anything)3940Most SQLite "optimisations" are unmeasured. Four rules, in order of how often they are41broken:42431. **Measure the statement, not the tool call.** An expensive aggregate that ships inside44 a batch another query was already sending costs *no extra round trip* and is therefore45 invisible to per-call timing — while still scanning the whole table on every request.46 Decompose multi-part statements and time each part separately.472. **Report latency AND rows scanned.** They move independently. An optimisation can cut48 latency ~25x while leaving rows-read essentially unchanged (and on a billed engine like49 D1, rows read is the money metric — see `references/d1-edge.md`).503. **Never trust wall-clock time from a CLI.** Process startup dominates. Use the engine's51 own reported duration (`.timer on` in the CLI, `meta.timings.sql_duration_ms` on D1).524. **Take a median of 10+ runs and report the range.** First runs are cold. In one measured53 session a cold run hit 2,495 ms against a 171 ms median on the same statement — a54 1.5–1.7x first-run penalty was routine on multi-thousand-row reads.5556```bash57# sqlite3 CLI: engine-reported timing, not shell time58sqlite3 app.db '.timer on' "SELECT count(*) FROM q_product WHERE org LIKE '%acme%';"5960# What the planner thinks the data looks like (empty = ANALYZE never ran)61sqlite3 app.db 'SELECT * FROM sqlite_stat1;'62```6364### Prove an index will help *before* you create it6566The highest-leverage trick in this skill, and the one that keeps schema work inside a67deploy gate: **run the identical statement shape against a column an existing index68already covers.** Same table, same row count, same predicate shape — only the column69changes. The difference is your projected payoff, measured on live production data with70**zero schema writes**.7172```sql73-- Hypothesis: a covering index on (org, product_id) makes this fast.74-- Unindexed control (what you have today):75SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';7677-- Proof shot: same shape, over a column an existing index already covers.78-- If this is fast, the index is worth writing. If it isn't, the index is not your problem.79SELECT DISTINCT org FROM q_product WHERE org LIKE '%acme%';80```8182In the worked example below the proof shot returned 6.75 ms against a 171.83 ms control —83enough to justify the index without touching production schema.8485## EXPLAIN QUERY PLAN — the 60-second read8687`EXPLAIN QUERY PLAN` (EQP) is the first command for any slow statement. It is cheap, safe,88read-only, and available on every host that lets you run arbitrary SQL.8990```sql91EXPLAIN QUERY PLAN92SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%';93```9495| Plan line | Means | Verdict |96|---|---|---|97| `SEARCH t USING INDEX ix (col=?)` | B-tree seek, touches matching rows only | Best case |98| `SEARCH t USING COVERING INDEX ix` | Seek, and every needed column is in the index — table never read | Best case |99| `SCAN t USING COVERING INDEX ix` | Full pass, but over narrow index entries, not wide rows | Often fine — see below |100| `SCAN t USING INDEX ix` | Full pass over the index **and** a row fetch per hit | Suspicious: the index is buying little |101| `SCAN t` | Full table scan | Fix it, unless the table is tiny |102| `USE TEMP B-TREE FOR ORDER BY` | Sorting because no index supplies the order | Cost signal |103| `USE TEMP B-TREE FOR GROUP BY` | Same, for grouping | Cost signal |104| `CORRELATED SCALAR SUBQUERY` | Subquery re-executed per outer row | Usually the whole problem |105106**The distinction that matters most:** `SCAN … USING COVERING INDEX` is not a failure.107A covering scan reads narrow index entries instead of paging in wide rows, which is exactly108how you make an *unseekable* predicate fast.109110**Deep dive**: `./references/query-performance.md` — index design, column order, partial and111expression indexes, ANALYZE/`sqlite_stat1`, and the full catalogue of planner defeats.112113### The unseekable-predicate trap (worked example)114115A leading-wildcard `LIKE '%x%'` can **never** use a B-tree — SQLite optimises `LIKE` only116for an anchored prefix (`'x%'`). So a plain index on that column changes nothing, people117observe no improvement, and conclude "indexing didn't help here". The index wasn't wrong;118the *shape* was. The fix is to make the scan **covering**, so the unavoidable full pass119reads narrow index entries instead of wide rows.120121```sql122-- Column order is load-bearing: FILTERED column first, PROJECTED column second.123CREATE INDEX q_product_org_product ON q_product(org, product_id);124```125126> **Worked example — one database, not a constant.** Measured 2026-08-04 against a live127> Cloudflare D1 (`atdw-mirror`, region OC, colo SYD), 12 runs each, median of server-side128> `sql_duration_ms`; 73-column table, 58k rows.129> Before: `SCAN q_product USING INDEX q_product_org`, **171.83 ms**, 60,736 rows read.130> The identical statement shape over an already-covered column: **6.75 ms**, 58,433 rows131> read. **~25x faster with rows-read essentially unchanged** — proof that the win came from132> row width, not from touching fewer rows. Your table's numbers will differ; the *shape* of133> the result is what transfers.134>135> Two further findings from the same session worth internalising:136> - Once the covering index existed, SQLite **dropped the `GROUP BY` temp B-tree by itself**.137> A hand-rewrite to avoid the grouping measured 5.99 ms vs 5.85 ms — noise. Don't138> hand-optimise around a temp B-tree until you have re-read the plan post-index.139> - An unindexed `MAX()` riding inside a batch another query was already sending cost140> **28.09 ms and 58,432 rows scanned on every response across four tools**, while the141> statement without it cost 0.17 ms / 2 rows. The same `MAX()` over an indexed column:142> 0.17 ms / 1 row. It never showed up in per-query timing because it added no round trip.143144### Verify the planner's choice with and without statistics145146A covering index may only be *chosen* once `ANALYZE` has populated `sqlite_stat1` — and147many hosted engines never run `ANALYZE` for you. Test both states before you rely on it:148149```sql150ANALYZE; -- populate sqlite_stat1151EXPLAIN QUERY PLAN SELECT ...; -- record the plan152153DELETE FROM sqlite_stat1; -- simulate a never-analyzed database154ANALYZE sqlite_master; -- force the planner to reload (now-empty) stats155EXPLAIN QUERY PLAN SELECT ...; -- same plan? then you are safe either way156```157158In the worked example the covering index was chosen in **both** states — verified, not159assumed. Do the same check rather than inheriting that result.160161## Index design in one table162163| Predicate shape | Indexable? | What to build |164|---|---|---|165| `col = ?`, `col IN (…)`, `col > ?`, `BETWEEN` | Yes | B-tree on `col` |166| `a = ? AND b = ?` | Yes | Composite `(a, b)` — equality columns first |167| `a = ? ORDER BY b` | Yes | Composite `(a, b)` — kills the temp B-tree |168| `col LIKE 'x%'` (anchored) | Yes, if `col` is TEXT with `BINARY` collation | B-tree on `col` |169| `col LIKE '%x%'` (leading wildcard) | **No seek possible** | Make the scan covering, or use FTS5 trigram |170| `lower(col) = ?` | Not on a plain index | Expression index `ON t(lower(col))` |171| `status = 'open'` where 2% of rows qualify | Yes | Partial index `WHERE status = 'open'` |172| `json_extract(doc,'$.k') = ?` | Not on a plain index | Expression index, or generated column + index |173174**Rules that repay themselves:** put the *filtered* column first and the *projected*175column second in a covering index; index the column, never a function of it (unless it is176an expression index); and every index you add taxes every write — audit before adding.177178## Concurrency and durability — the 80/20179180| Symptom | Cause | Fix |181|---|---|---|182| `SQLITE_BUSY` | Another **connection** holds a lock; yours gave up waiting | `PRAGMA busy_timeout = 5000;` and keep write transactions short |183| `SQLITE_LOCKED` | Conflict **within the same connection** (or a shared cache) | Fix the code — a retry loop will spin forever |184| "database is locked" mid-transaction | `BEGIN` (DEFERRED) read that later writes → upgrade deadlock, **not retryable** | `BEGIN IMMEDIATE` for any transaction that will write |185| Readers blocked by a writer | Rollback journal mode | `PRAGMA journal_mode = WAL;` (persistent, set once) |186| `-wal` file grows without bound | Long-lived reader pins the checkpoint | Close/refresh readers; `PRAGMA wal_checkpoint(TRUNCATE);` |187188```sql189PRAGMA journal_mode = WAL; -- persistent; survives reconnect190PRAGMA busy_timeout = 5000; -- per-connection; set on EVERY connection191PRAGMA foreign_keys = ON; -- per-connection, OFF by default — see below192PRAGMA synchronous = NORMAL; -- safe with WAL; FULL only if you fear power loss193```194195**Deep dive**: `./references/concurrency-durability.md` — WAL internals, the196DEFERRED-upgrade deadlock, `synchronous` levels, checkpoint starvation, multi-process access.197198## Schema — the three silent bugs1992001. **`PRAGMA foreign_keys` is OFF by default.** Per connection, every connection. Your201 `REFERENCES` clauses parse, are stored, and do nothing. This is the classic silent202 data-integrity bug in SQLite applications.2032. **Type affinity is not a type.** A `TEXT` column will happily store an integer; a204 declared type is a *suggestion* about conversion. Use **`STRICT` tables** (SQLite 3.37+)205 when you want a declared type enforced.2063. **`ALTER TABLE` is limited.** Adding a column and renaming are supported; dropping,207 retyping, and changing constraints need the 12-step recreate dance.208209```sql210CREATE TABLE product (211 id INTEGER PRIMARY KEY,212 org TEXT NOT NULL,213 price REAL NOT NULL,214 doc TEXT,215 -- indexable projection of a JSON field216 sku TEXT GENERATED ALWAYS AS (json_extract(doc, '$.sku')) VIRTUAL217) STRICT;218```219220**Deep dive**: `./references/schema-design.md` (affinity, STRICT, generated columns,221`WITHOUT ROWID`, constraints) and `./references/migration-patterns.md` (the 12-step ALTER222dance, versioned migration runners).223224## Feature modules at a glance225226| Need | Reach for | Note |227|---|---|---|228| Substring / fuzzy text search | FTS5 with the `trigram` tokenizer | The real answer to `LIKE '%x%'` at scale |229| Word/phrase search with ranking | FTS5 + `bm25()` | External-content table avoids duplicating the corpus |230| Semi-structured documents | `json_extract` / `->` / `->>`, JSONB (3.45+) | Index via generated column or expression index |231| Bounding-box / interval overlap | R-tree virtual table | Compile-time module; check availability |232| Running totals, ranking, gaps | Window functions (3.25+) | Same syntax as PostgreSQL |233| Insert-or-update | `ON CONFLICT … DO UPDATE` (3.24+) | `excluded.col` refers to the proposed row |234| Read back what you wrote | `RETURNING` (3.35+) | Makes atomic claim-a-job patterns single-statement |235236**Deep dive**: `./references/feature-modules.md`.237238## Hosts239240The engine is the same; the envelope is not.241242| Host | Connection model | Watch out for |243|---|---|---|244| `sqlite3` CLI | Direct file | `.timer on` for real timings; `.mode`/`.headers` for output |245| Python `sqlite3` | Direct file, per-connection pragmas | Implicit transaction handling; `check_same_thread` |246| Python `aiosqlite` | Thread-backed async wrapper | Still one writer; see `./references/async-patterns.md` |247| `node:sqlite` | Synchronous, built into Node | No external dependency; API still stabilising |248| `better-sqlite3` | Synchronous, native addon | Fastest Node option; prepared statements are the unit of reuse |249| `bun:sqlite` | Synchronous, built into Bun | API close to better-sqlite3, not identical |250| **Cloudflare D1** | HTTP/RPC to a managed SQLite | Billed on **rows read**; 100-parameter cap; no `PRAGMA` surface |251| libSQL / Turso | Server or embedded replica | Replica staleness; syntax extensions beyond stock SQLite |252253**Deep dive**: `./references/hosts.md` for per-host connection recipes and traps.254255On D1 specifically, three platform features have no stock-SQLite equivalent and are the most256commonly missed:257258```bash259wrangler d1 insights <db> --sort-type=sum --sort-by=reads --limit=10 # rank REAL queries by cost260wrangler d1 time-travel info <db> # 30-day point-in-time restore point261# Sessions API (env.DB.withSession(bookmark)) - read replicas, sequential consistency262```263264`./references/d1-edge.md` covers those plus the rows-read economics, the verified limits265table, the error catalogue, and import/export. For the production incident patterns —266a timed-out `migrations apply --remote` that landed anyway, `batch()` treating a 0-row267scoped UPDATE as success, and the opt-in-to-replica rollout shape for read replication —268see `./references/d1-production-patterns.md`.269270## Operations271272```bash273sqlite3 app.db 'PRAGMA quick_check;' # fast structural check274sqlite3 app.db 'PRAGMA integrity_check;' # full check — slow on big DBs275sqlite3 app.db "VACUUM INTO 'backup.db';" # consistent backup, no downtime, defragmented276sqlite3 app.db '.dump' > backup.sql # portable text backup277sqlite3 app.db 'PRAGMA optimize;' # run before closing a long-lived connection278```279280**Never** copy a live database file with `cp` while a writer is active — use281`VACUUM INTO`, the backup API, or `.dump`.282283**Deep dive**: `./references/operations.md` — corruption causes and recovery, `VACUUM` vs284`VACUUM INTO`, page/cache sizing, size analysis.285286## Triage script287288`scripts/eqp-triage.py` reads an `EXPLAIN QUERY PLAN` result — either by running the289statement against a database, or from piped plan text — and classifies each line by290severity with a fix hint. Exits `10` when it finds something (the domain signal), `0`291when the plan is clean.292293```bash294# Run against a database file (uses Python's bundled sqlite3 — no external binary needed)295python3 scripts/eqp-triage.py --db app.db \296 --sql "SELECT DISTINCT product_id FROM q_product WHERE org LIKE '%acme%'"297298# Triage a plan captured elsewhere (D1, a log, a colleague's paste)299wrangler d1 execute atdw-mirror --remote --json \300 --command "EXPLAIN QUERY PLAN SELECT product_id FROM q_product WHERE org LIKE '%acme%'" \301 | python3 scripts/eqp-triage.py302303# Machine-readable findings304python3 scripts/eqp-triage.py --db app.db --sql "SELECT ..." --json | jq '.data[]'305```306307## Gotchas308309| Mistake | Why it bites | Fix |310|---|---|---|311| Adding an index for `LIKE '%x%'` | Leading wildcard can never seek | Covering index, or FTS5 trigram |312| Timing with a shell stopwatch | CLI/driver startup dominates | Engine-reported duration; median of 10+ |313| Timing the tool call, not the statement | Piggy-backed statements are invisible | Decompose and time each part |314| Assuming `REFERENCES` is enforced | `foreign_keys` is OFF per connection | `PRAGMA foreign_keys = ON` on every connection |315| Assuming a declared type is enforced | Affinity, not typing | `STRICT` tables |316| Retrying `SQLITE_LOCKED` | Same-connection conflict never clears | Fix the code path |317| `BEGIN` then write | DEFERRED→write upgrade deadlocks and is not retryable | `BEGIN IMMEDIATE` |318| `cp` on a live database | Torn copy | `VACUUM INTO` / backup API |319| `SELECT *` | Defeats covering indexes; widens every row read | Project only what you need |320| `VACUUM` to "speed things up" | Rewrites the whole file, needs 2x space, holds a lock | `PRAGMA optimize` / targeted index work |321| Trusting one cold run | 1.5–1.7x first-run penalty is routine | Median of 10+, report the range |322| Inlining literals to dodge a parameter cap | That is how injection happens | Chunk the work; keep bound parameters |323| Re-running a timed-out remote migration | The apply may have landed; the error was about the response | Verify schema state read-only first — `./references/d1-production-patterns.md` |324| Reading a committed `batch()` as per-statement success | A conditional UPDATE matching 0 rows is not an error | Check `meta.changes`; 0 on a scoped write = 403/conflict |325326## Reference files327328| Reference | Load when |329|---|---|330| `./references/query-performance.md` | Any slow statement: EQP, index design, ANALYZE, planner defeats, measurement method |331| `./references/d1-edge.md` | Cloudflare D1: rows-read economics, `d1 insights`, Sessions API/replication, Time Travel, limits, errors |332| `./references/d1-production-patterns.md` | Running D1 in production: verifying a timed-out migration, `batch()` 0-row write verification, the opt-in-to-replica replication rollout |333| `./references/concurrency-durability.md` | Locking, WAL, busy_timeout, transaction modes, checkpointing, durability |334| `./references/schema-design.md` | Affinity, STRICT, foreign keys, generated columns, `WITHOUT ROWID`, constraints |335| `./references/schema-patterns.md` | Ready-made table designs: state, cache, event log, queue, session, dedup |336| `./references/migration-patterns.md` | Versioned migrations, the 12-step ALTER dance, host-specific runners |337| `./references/feature-modules.md` | FTS5, JSON/JSONB, R-tree, window functions, upsert, RETURNING |338| `./references/hosts.md` | Per-host connection recipes and driver traps (Python, Node, Bun, D1, libSQL) |339| `./references/async-patterns.md` | Python `aiosqlite` depth: async CRUD, batching, pooling |340| `./references/operations.md` | Integrity checks, corruption recovery, VACUUM, backups, size and page tuning |341| `./references/testing.md` | In-memory vs file databases, fixtures, deterministic seeding, migration tests |342343## See also344345| Skill | When to combine |346|---|---|347| `sql-ops` | Vendor-neutral SQL: CTEs, window functions, JOIN strategy |348| `perf-ops` | The wider performance workflow — profiling, load testing, before/after protocol |349| `cloudflare-ops` | Workers, bindings, and deployment around a D1 database |350| `postgres-ops` | When the workload has outgrown SQLite's single-writer model |351| `python-database-ops` | SQLAlchemy / ORM layers over SQLite |