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.
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.
Diagnose common SQL/application anti-patterns from plans, statement counts, timings, and contention evidence.
Replace N+1 and chatty loops with set-oriented/batched access when semantics permit.
Distinguish missing indexes from overlapping indexes and unnecessary maintenance cost.
Recognize JSON scans, long readers, huge transactions, and write contention as workload-shape problems.
Identify deployment/storage-placement signals that SQLite is being used outside its assumptions.
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.
-- 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.
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.
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;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.
-- 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.
| Smell | Evidence | First design question |
|---|---|---|
| Transaction open during HTTP request | Trace shows BEGIN, long idle gap, then COMMIT. | Can external work happen before BEGIN or after COMMIT? |
| One-row commits | Commit count approximately equals row count. | Can business operations share a bounded atomic batch? |
| Minute-long bulk write | Writer waits/large WAL/retry pain. | Can job use chunks with idempotent progress? |
| Busy timeout keeps increasing | Latency 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.
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.
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 countStorage 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.
| Signal | Interpretation |
|---|---|
| Database file on network share with variable lock semantics | Deployment architecture may violate SQLite locking/WAL assumptions. |
| Central service with many independent write clients | Single-writer serialization may be the architectural bottleneck. |
| Large analytical scans compete with OLTP writes | A separate analytical engine/read model may fit better. |
| Database copied among machines for coordination | SQLite file is being used like distributed state; use a coordination/server design. |
| Most latency is app serialization/API calls | SQL 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.
| Need | Likely direction |
|---|---|
| Many remote concurrent writers + server auth | PostgreSQL/MySQL/MariaDB or another client/server transactional database. |
| Local embedded application with modest writes | SQLite may still be ideal; fix transaction/query design first. |
| Large local analytical scans over Parquet/columnar data | DuckDB or analytical engine may complement/replace the query workload. |
| Search-heavy language relevance | FTS5 may fit local search; specialized search service may fit distributed scale. |
| SQLite write queue saturated despite short transactions/batching | Quantify throughput requirement and evaluate client/server architecture. |
Mini performance review: FieldNotes
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 evidenceExample prioritized tuning report
| Priority | Evidence | Action | Validation |
|---|---|---|---|
| P0 | Someone proposed synchronous=OFF to pass throughput test. | Reject unless data is explicitly disposable/rebuildable. Preserve required durability. | Failure model review + same-setting benchmark. |
| P1 | Importer: 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. |
| P1 | Device screen issues 101 SQL statements. | Replace N+1 with set-oriented latest-note query. | Statement count, plan, end-to-end latency, same result set. |
| P2 | WAL grows during 40-minute report read transaction. | Stream/copy report data and shorten read transaction; revisit checkpoint ownership. | WAL size and PASSIVE checkpoint progress. |
| P2 | Two indexes share same leading columns; one never appears in plans. | Candidate consolidation after workload replay. | Read plans + insert benchmark + DB size. |
| P3 | Blog 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.”
- What evidence should exist before changing a PRAGMA?
- Why is transaction batching usually safer to test before synchronous=OFF?
- What does PRAGMA optimize solve, and what does it not solve?
- How can a long reader cause a performance symptom in WAL mode?
- When is migration to a client/server database a performance fix rather than a failure of tuning?
- 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.