Chapter 18 · Performance Engineering, PRAGMAs, Maintenance, and Benchmarking

Performance Anti-Patterns and When SQLite Has Reached the Wrong Workload

Diagnose query, transaction, JSON, concurrency, and deployment bottlenecks with evidence, prioritize fixes, and recognize when application architecture or database choice—not another PRAGMA—is the real constraint.

Beginner120–150 minutesFieldNotes performance review + tuning reportSQLite 3.53.4 baselineArchitecture reassessment is a valid resultLast reviewed: August 2026

Learning outcomes

Performance work is complete only when it can say do not tune SQLite here. A slow system may spend its time in N+1 application loops, duplicate indexes, JSON traversal, long transactions, blocked writers, remote filesystems, serialization, or simply a workload that wants a client/server or analytical engine. This final lesson turns measurements into a prioritized engineering report.

01

Diagnose common SQL/application anti-patterns from plans, statement counts, timings, and contention evidence.

02

Replace N+1 and chatty loops with set-oriented/batched access when semantics permit.

03

Distinguish missing indexes from overlapping indexes and unnecessary maintenance cost.

04

Recognize JSON scans, long readers, huge transactions, and write contention as workload-shape problems.

05

Identify deployment/storage-placement signals that SQLite is being used outside its assumptions.

06

Produce a prioritized FieldNotes tuning report with evidence, expected benefit, risk, and validation plan.

Anti-pattern 1: N+1 queries

An N+1 pattern fetches a parent list, then runs one additional query for every parent. The individual statements can each be “fast” while the application performs hundreds of prepare/step/map cycles.

sql · chatty application shape
-- First querySELECT device_id, device_code FROM device WHERE status='active';-- Then the application repeats this once per returned device:SELECT occurred_at, severity, note_textFROM maintenance_noteWHERE device_id=?ORDER BY occurred_at DESCLIMIT 1;

Measure statement count per user operation, not only statement duration. A window query, correlated subquery, join/CTE, or explicit batched IN request may reduce round trips/driver overhead depending on the exact result shape.

sql · one set-oriented option
WITH ranked AS (  SELECT n.*,         row_number() OVER (           PARTITION BY device_id ORDER BY occurred_at DESC, note_id DESC         ) AS rn  FROM maintenance_note AS n)SELECT d.device_id, d.device_code,       r.occurred_at, r.severity, r.note_textFROM device AS dLEFT JOIN ranked AS r  ON r.device_id=d.device_id AND r.rn=1WHERE d.status='active';

Anti-pattern 2: missing, redundant, or oversized indexes

A missing index can force a large scan; too many overlapping indexes increase every INSERT/UPDATE/DELETE and consume file/cache space. Chapter 10 taught planner evidence. A performance review should list every persistent index beside the queries that justify it.

sql · index inventory
SELECT name, tbl_name, sqlFROM sqlite_schemaWHERE type='index'ORDER BY tbl_name, name;PRAGMA index_list('maintenance_note');PRAGMA index_xinfo('idx_note_device_time');EXPLAIN QUERY PLANSELECT note_id, occurred_at, severityFROM maintenance_noteWHERE device_id=?ORDER BY occurred_at DESC LIMIT 25;
Do not delete “redundant-looking” indexes from names alone

A shorter index may support a different sort/order/covering pattern or uniqueness constraint. Compare definitions and actual query plans, then measure write/read effects on a representative database.

Anti-pattern 3: SELECT * and unnecessary work

SELECT * is not automatically slow, but it can force SQLite and the driver to read, decode, allocate, and copy columns the caller never uses—especially wide TEXT/BLOB/JSON payloads. It can also prevent a smaller covering index from satisfying the query.

sql · measure narrow versus wide result
-- UI list only needs these columns:SELECT note_id, occurred_at, severityFROM maintenance_noteWHERE device_id=?ORDER BY occurred_at DESCLIMIT 50;-- Compare with SELECT * only if the UI genuinely needs every column.-- Capture plan, bytes/row if relevant, and end-to-end mapping time.

Likewise, an ORDER BY or DISTINCT that exists only because “the old query had it” can create a temporary b-tree. Use EXPLAIN QUERY PLAN to find USE TEMP B-TREE, then decide whether the order is semantically required and whether an index can support it.

Anti-pattern 4: huge transactions and chatty transactions

Both extremes can hurt. A transaction that pauses for user input or HTTP calls keeps database resources occupied for no database reason. A huge batch can monopolize the single writer and make rollback/retry expensive. Thousands of tiny commits can spend most time on durability boundaries.

SmellEvidenceFirst design question
Transaction open during HTTP requestTrace shows BEGIN, long idle gap, then COMMIT.Can external work happen before BEGIN or after COMMIT?
One-row commitsCommit count approximately equals row count.Can business operations share a bounded atomic batch?
Minute-long bulk writeWriter waits/large WAL/retry pain.Can job use chunks with idempotent progress?
Busy timeout keeps increasingLatency becomes waiting rather than throughput.Which transaction is holding the writer, and why so long?

Anti-pattern 5: unbounded JSON traversal

JSON is useful for flexible metadata, but repeatedly traversing a large JSON document for a field used in hot predicates hides schema and indexability. If a path becomes operationally important, promote it to a real/generated column or an expression index with validated query matching—as Chapter 13 demonstrated.

sql · hot JSON predicate review
EXPLAIN QUERY PLANSELECT device_idFROM device_profileWHERE json_extract(metadata, '$.protocol')='modbus';-- If this is a hot stable property, compare against:-- 1) indexed generated column-- 2) expression index with exact expression matching-- 3) promoted ordinary relational column-- Measure write/storage cost too.

Anti-pattern 6: long readers and write contention

WAL improves reader/writer overlap, but a long-lived read transaction can pin an old WAL end mark and prevent checkpoints from completing. Multiple writers still serialize. Diagnose with transaction lifetime and checkpoint/busy evidence, not by repeatedly increasing busy_timeout.

text · concurrency evidence to capture
For each SQLITE_BUSY incident:  timestamp  operation + transaction start time  connection/process identity  BEGIN mode  journal_mode + synchronous  busy timeout/retry policy  competing writer duration  long reader duration (WAL)  WAL size/checkpoint result  final success/failure + retry count

Storage placement can dominate SQL

SQLite is an embedded library coordinating a database file through its VFS and filesystem locking primitives. High-latency or semantically unsuitable network filesystems can turn ordinary page/journal operations into slow or unreliable behavior. Do not compensate with giant caches or timeouts until the deployment filesystem is explicitly supported and tested.

SignalInterpretation
Database file on network share with variable lock semanticsDeployment architecture may violate SQLite locking/WAL assumptions.
Central service with many independent write clientsSingle-writer serialization may be the architectural bottleneck.
Large analytical scans compete with OLTP writesA separate analytical engine/read model may fit better.
Database copied among machines for coordinationSQLite file is being used like distributed state; use a coordination/server design.
Most latency is app serialization/API callsSQL PRAGMAs are not the primary bottleneck.

When another database is the optimization

SQLite remains excellent for local application state, edge/desktop/mobile storage, caches, configuration, embedded services, test fixtures, and many moderate workloads. Reassessment becomes reasonable when the product requires many concurrent writers, centralized remote clients, server-managed authorization, replicas/failover, or analytics that are naturally columnar/distributed.

NeedLikely direction
Many remote concurrent writers + server authPostgreSQL/MySQL/MariaDB or another client/server transactional database.
Local embedded application with modest writesSQLite may still be ideal; fix transaction/query design first.
Large local analytical scans over Parquet/columnar dataDuckDB or analytical engine may complement/replace the query workload.
Search-heavy language relevanceFTS5 may fit local search; specialized search service may fit distributed scale.
SQLite write queue saturated despite short transactions/batchingQuantify throughput requirement and evaluate client/server architecture.

Mini performance review: FieldNotes

text · evidence-driven review worksheet
Workload: FieldNotes local/edge databaseEvidence collected:  [ ] top 10 operations by total DB time, not only average latency  [ ] statement count per operation (find N+1/chatty loops)  [ ] EXPLAIN QUERY PLAN for hot queries  [ ] index inventory + write rate  [ ] transaction duration + commits/sec + busy/retries  [ ] WAL size/checkpoint + long-reader duration  [ ] database size/page_count/freelist_count  [ ] JSON hot-path queries  [ ] sqlite_version/source_id/compile_options + PRAGMAs  [ ] host filesystem/storage contextPrioritize each finding:  P0 correctness/durability risk  P1 high total latency/throughput bottleneck  P2 measurable efficiency/storage issue  P3 speculative idea -- DO NOT implement without evidence

Example prioritized tuning report

PriorityEvidenceActionValidation
P0Someone proposed synchronous=OFF to pass throughput test.Reject unless data is explicitly disposable/rebuildable. Preserve required durability.Failure model review + same-setting benchmark.
P1Importer: 2,000 commits for 2,000 rows; commit dominates elapsed time.Use bounded 250–500-row atomic chunks with job idempotency.Rows/s, max chunk latency, retry drill, count/checksum.
P1Device screen issues 101 SQL statements.Replace N+1 with set-oriented latest-note query.Statement count, plan, end-to-end latency, same result set.
P2WAL grows during 40-minute report read transaction.Stream/copy report data and shorten read transaction; revisit checkpoint ownership.WAL size and PASSIVE checkpoint progress.
P2Two indexes share same leading columns; one never appears in plans.Candidate consolidation after workload replay.Read plans + insert benchmark + DB size.
P3Blog recommends 1 GiB cache and 30 GiB mmap.No action: no cache/mmap bottleneck evidence.Only test if profiling identifies relevant misses/I/O.

Chapter synthesis

Performance engineering review

Answer without using the phrase “make it faster.”

  1. What evidence should exist before changing a PRAGMA?
  2. Why is transaction batching usually safer to test before synchronous=OFF?
  3. What does PRAGMA optimize solve, and what does it not solve?
  4. How can a long reader cause a performance symptom in WAL mode?
  5. When is migration to a client/server database a performance fix rather than a failure of tuning?
  6. Why must every tuning report include correctness/durability validation?
Review the answers

You need a defined workload, baseline configuration, plans/metrics, and a hypothesis. Batching can reduce repeated transaction work without weakening durability. optimize maintains planner statistics/selected optimizations; it does not compact files or remove writer serialization. Long readers can block checkpoint progress and grow WAL. If many remote/concurrent writers or centralized service requirements exceed SQLite’s architecture, a server database can be the right design. A faster wrong/corruptible result is not an optimization.

Bridge to security and deployment

Performance settings are inseparable from reliability and deployment. Chapter 19 moves to trust boundaries: SQL injection, file permissions, encryption reality, trusted_schema, extension loading, local versus network filesystems, and the final decision about when SQLite is—and is not—the right database.

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.