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.

Beginner125–150 minutesBenchmark harness + plan/timing evidenceSQLite 3.53.4 baselineSQLite 3.53.4 baseline · .timer is CLI-specificLast reviewed: August 2026

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.

01

Define a benchmark question that names the operation, dataset, concurrency, durability, and success metric.

02

Record SQLite version, schema, query plan, PRAGMAs, row distribution, file size, and environment before timing.

03

Use EXPLAIN QUERY PLAN and repeated host-language timing together rather than treating either as sufficient alone.

04

Distinguish warm-cache, cold-ish-cache, and process-restart experiments without pretending they are identical.

05

Use the sqlite3 CLI .timer feature where available while recognizing that shell timing includes shell-side work.

06

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 fieldExample
OperationLatest 20 readings for one device; 5,000-row import transaction.
Dataset200k readings; 100 devices; deliberately skewed hot-device distribution.
Correctness invariantRead returns newest rows; load count/checksum matches input.
DurabilityRecord journal_mode and synchronous; do not silently weaken them.
ConcurrencySingle connection for baseline; separate experiment for concurrent reader/writer.
MetricMedian elapsed time plus tail observations; rows/s for bulk load.
Plan evidenceEXPLAIN QUERY PLAN text captured for the exact SQL.
EnvironmentSQLite/source ID, Python/CLI version, OS/filesystem, storage type, DB/WAL size.
The baseline is part of the result

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.

sql · baseline schema
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.

sql · plan for the hot read
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.

text · interactive shell workflow
.version.timer on.eqp onSELECT reading_id, observed_at, value_realFROM perf_readingWHERE device_id = 17ORDER BY observed_at DESCLIMIT 20;.eqp off.timer off

Do 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.

python · repeatable benchmark skeleton
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.

ExperimentWhat it approximatesWhat it does not prove
Repeat same query on one connectionWarm SQLite + likely warm OS cache.First-open latency or disk-miss cost.
Close/reopen connectionFresh SQLite connection/page cache.Cold OS filesystem cache.
Restart processFresh process and SQLite connection.Cold OS/device caches.
OS-specific cache purge in a test machineCloser 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.

Benchmark the boundary you own

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.

text · benchmark evidence checklist
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 evidence

Checkpoint

Can you trust this benchmark?

Decide whether each claim is supported.

  1. Query A took 2 ms once, so it is twice as fast as Query B which took 4 ms once.
  2. A reopened connection is guaranteed to be a cold-disk test.
  3. An indexed SEARCH plan is useful evidence, but not proof of lower wall time.
  4. Turning synchronous OFF makes a benchmark incomparable to the original durability requirement.
  5. 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.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.