Chapter 18 · Performance Engineering, PRAGMAs, Maintenance, and Benchmarking
Measure First: Timers, Query Plans, Dataset Shape, and Repeatable Benchmarks
Build a reproducible SQLite performance workflow that records workload shape, plans, runtime context, repeated timings, and correctness before any tuning change.
Learning outcomes
Performance engineering starts before the stopwatch. A fast result on a tiny laptop database can be irrelevant to a production workload whose rows, transaction boundaries, durability requirements, cache state, concurrency, or filesystem differ. In this lesson you will build an evidence record that another engineer can reproduce and challenge.
Define a benchmark question that names the operation, dataset, concurrency, durability, and success metric.
Record SQLite version, schema, query plan, PRAGMAs, row distribution, file size, and environment before timing.
Use EXPLAIN QUERY PLAN and repeated host-language timing together rather than treating either as sufficient alone.
Distinguish warm-cache, cold-ish-cache, and process-restart experiments without pretending they are identical.
Use the sqlite3 CLI .timer feature where available while recognizing that shell timing includes shell-side work.
Build a reusable FieldNotes benchmark harness for inserts and indexed reads, with correctness verification.
Start with a question, not a setting
“Make SQLite faster” is not a benchmark question. A useful question identifies the exact operation and acceptable tradeoffs. For example: Can FieldNotes return the newest 20 readings for one device in under our application latency budget on a representative 200,000-row local database while preserving the existing durability policy?
| Benchmark field | Example |
|---|---|
| Operation | Latest 20 readings for one device; 5,000-row import transaction. |
| Dataset | 200k readings; 100 devices; deliberately skewed hot-device distribution. |
| Correctness invariant | Read returns newest rows; load count/checksum matches input. |
| Durability | Record journal_mode and synchronous; do not silently weaken them. |
| Concurrency | Single connection for baseline; separate experiment for concurrent reader/writer. |
| Metric | Median elapsed time plus tail observations; rows/s for bulk load. |
| Plan evidence | EXPLAIN QUERY PLAN text captured for the exact SQL. |
| Environment | SQLite/source ID, Python/CLI version, OS/filesystem, storage type, DB/WAL size. |
If you change the schema, dataset, transaction shape, durability mode, cache state, or host between “before” and “after,” you have changed multiple variables. Record those changes instead of calling the comparison controlled.
A reusable FieldNotes benchmark dataset
The chapter uses a disposable perf_reading table. The row shape is intentionally simple enough to understand but large enough for planner choices and batching costs to become visible on ordinary hardware.
DROP TABLE IF EXISTS perf_reading;CREATE TABLE perf_reading( reading_id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, observed_at TEXT NOT NULL, metric TEXT NOT NULL, value_real REAL NOT NULL, payload TEXT);-- The benchmark harness inserts many rows before creating this index.CREATE INDEX idx_perf_device_timeON perf_reading(device_id, observed_at DESC);The access pattern—not the existence of a column—justifies the index. Chapter 10 already established that an index has read benefits and write/storage costs. Here we measure those costs in the workload that matters.
Capture query-plan evidence
Timing a query without its plan makes regressions difficult to diagnose. A query can remain fast on a small dataset even after it stops using an index. Conversely, a changed plan is not automatically worse; measure it.
EXPLAIN QUERY PLANSELECT reading_id, observed_at, value_realFROM perf_readingWHERE device_id = 17ORDER BY observed_at DESCLIMIT 20;With the composite index present, a current build will normally report a SEARCH using idx_perf_device_time. If your exact output differs, preserve the output: EXPLAIN QUERY PLAN text is a debugging interface and can change across SQLite releases.
CLI timing is useful, but it is not the whole benchmark
The current sqlite3 shell provides .timer on|off. It is excellent for interactive comparisons because it puts elapsed timing beside the statement you are inspecting. It is a shell tool, not SQL, and its elapsed measurement includes shell work around SQLite API calls.
.version.timer on.eqp onSELECT reading_id, observed_at, value_realFROM perf_readingWHERE device_id = 17ORDER BY observed_at DESCLIMIT 20;.eqp off.timer offDo not copy one displayed time into a capacity plan. Repeat the operation and use the same output mode, result size, host load, and database state. If output formatting dominates a query that returns thousands of rows, measure the application path too.
Host-language benchmark harness
Python’s perf_counter_ns() gives a monotonic high-resolution timer suitable for elapsed-time comparisons. The harness below uses a file-backed database because journal synchronization and filesystem behavior are part of the workload. It records median and minimum rather than presenting one lucky run.
from pathlib import Pathimport platform, sqlite3, statistics, timeDB = Path("fieldnotes_perf.sqlite")con = sqlite3.connect(DB)print("python:", platform.python_version())print("sqlite:", sqlite3.sqlite_version)print("source:", con.execute("select sqlite_source_id()").fetchone()[0])for name in ("journal_mode", "synchronous", "cache_size", "temp_store", "mmap_size"): print(name, con.execute(f"pragma {name}").fetchone()[0])sql = """SELECT reading_id, observed_at, value_real FROM perf_reading WHERE device_id=? ORDER BY observed_at DESC LIMIT 20"""plan = con.execute("EXPLAIN QUERY PLAN " + sql, (17,)).fetchall()print("plan:", plan)def timed_read(device_id, trials=15): samples = [] expected = None for _ in range(trials): t0 = time.perf_counter_ns() rows = con.execute(sql, (device_id,)).fetchall() samples.append((time.perf_counter_ns() - t0) / 1e6) expected = rows assert len(expected) <= 20 return statistics.median(samples), min(samples), samplesmedian_ms, best_ms, samples = timed_read(17)print("median_ms:", round(median_ms, 3))print("best_ms:", round(best_ms, 3))print("samples_ms:", [round(x, 3) for x in samples])con.close()The first trial often includes work that later trials do not: page faults, filesystem cache population, statement preparation, or CPU-frequency changes. That is why the raw sample list belongs in the evidence record.
Warm cache, cold cache, and honest labels
SQLite has its own page cache, the operating system has a filesystem cache, and storage devices may have additional caches. Reopening a SQLite connection clears SQLite’s connection page cache but does not guarantee the OS has forgotten the file. A true cold-storage benchmark is platform-specific and can be intrusive.
| Experiment | What it approximates | What it does not prove |
|---|---|---|
| Repeat same query on one connection | Warm SQLite + likely warm OS cache. | First-open latency or disk-miss cost. |
| Close/reopen connection | Fresh SQLite connection/page cache. | Cold OS filesystem cache. |
| Restart process | Fresh process and SQLite connection. | Cold OS/device caches. |
| OS-specific cache purge in a test machine | Closer to storage-cold behavior. | Normal production steady state; safe use on shared machines. |
Name the condition you actually measured. “Cold query” should not mean “I reopened Python.”
Why microbenchmarks mislead
A microbenchmark can deliberately isolate one operation, which is useful. The mistake is extrapolating it to an application that adds transactions, fsync/FlushFileBuffers, network calls, JSON serialization, lock waits, UI work, long readers, or a much larger data distribution.
If the user experiences “save note” latency, benchmark the complete database transaction for saving the note—not only the 20-microsecond UPDATE inside it. If a bulk importer must survive interruption, include commit and verification cost rather than timing only row construction.
Lab: create the evidence package
Create a disposable file with at least tens of thousands of readings. Time inserts, create the composite index, inspect the hot read plan, then time repeated reads. Do not compare your absolute milliseconds to this course: your machine is the subject.
Benchmark ID: ch18-l1-fieldnotesQuestion: latest-20 read + baseline insert costRecord before run: [ ] sqlite_version() and sqlite_source_id() [ ] PRAGMA compile_options [ ] journal_mode / synchronous / cache_size / temp_store / mmap_size [ ] schema and indexes [ ] row count and distribution by device_id [ ] database/WAL file sizes [ ] OS, filesystem, storage medium, runtime versionFor each candidate change: [ ] make one intentional change [ ] verify row counts/business result [ ] capture EXPLAIN QUERY PLAN [ ] run repeated trials [ ] store raw samples, median, and tail observations [ ] decide keep/revert from evidenceCheckpoint
Can you trust this benchmark?
Decide whether each claim is supported.
- Query A took 2 ms once, so it is twice as fast as Query B which took 4 ms once.
- A reopened connection is guaranteed to be a cold-disk test.
- An indexed SEARCH plan is useful evidence, but not proof of lower wall time.
- Turning synchronous OFF makes a benchmark incomparable to the original durability requirement.
- A benchmark report should include the data distribution, not only the row count.
Review the answers
One trial is weak evidence; repeat and preserve samples. Reopening does not flush the OS cache. Plans explain access strategy but still need measurement. Weakening durability changes the workload contract. Distribution can determine selectivity and planner behavior, so total row count alone is insufficient.
Bridge to write throughput
With a measurement discipline in place, Lesson 2 investigates the most common SQLite write-performance mistake: treating every row as its own durable business transaction. We will change transaction boundaries first—without changing correctness requirements—and measure the result.