Install
npx skillscat add eliquid/predict-marketing-forecasting/sqlite-optimization Install via the SkillsCat registry.
SQLite Optimization
Version 0.1.0 · Jason Brown · MIT · linux, macos, windows · tags: sqlite, optimization, security, production, go
Apply this skill to Go services that use SQLite or a SQLite-compatible database. Optimize only after measuring the query and workload; preserve durability, correctness, and security before pursuing throughput. Do not add a database, ORM, cache, or background system without a demonstrated need.
When to Use
- Designing SQLite schema, connection setup, migrations, or production deployment.
- Investigating slow queries, lock contention, WAL growth, or unexpected
SQLITE_BUSYfailures. - Reviewing SQLite security, backups, integrity, or performance changes.
- Establishing measurable optimization baselines for a Go + SQLite service.
Do not use for cross-node, high-write-concurrency, or multi-primary workloads without first confirming SQLite fits the deployment and write model. SQLite permits one writer at a time, including in WAL mode.
Acceptance Criteria
Before calling SQLite work complete, verify all applicable items:
- Required invariants are database constraints, not only Go validation.
- Every connection has required connection-local PRAGMAs enabled and is health-checked.
- Changed query plans have been inspected with
EXPLAIN QUERY PLANusing representative data. - A measured baseline and post-change result exist, or the verification gap is explicit.
- Backups have a documented restore-and-integrity-check procedure.
- Security boundaries cover SQL injection, database-file access, extension loading, and secrets.
1. Fit the Workload and Set the Durability Contract
- Confirm the database lives on reliable local storage accessible to one deployment unit. Do not place SQLite/WAL files on network filesystems unless the SQLite documentation and the storage provider explicitly support the locking semantics.
- Identify write rate, transaction duration, reader concurrency, database size, available memory, recovery point objective, and recovery time objective. Establish these before selecting PRAGMAs.
- Choose durability deliberately:
synchronous=FULLwhen a power-loss durability guarantee is required for each committed transaction.- In WAL mode,
synchronous=NORMALis often a sound performance/durability trade-off: an application crash does not corrupt the database, but a system/power failure can lose recently committed work. - Never use
synchronous=OFFfor durable or security-relevant data.
- Record the choice and its failure-mode consequence in deployment documentation. Completion: a reviewer can tell which failures may lose acknowledged work.
2. Initialize and Verify Each Database Connection
Set persistent database-mode settings during creation/migration; set connection-local settings after every connection opens. Exact DSN hooks are driver-specific, so inspect the installed driver's documentation rather than guessing DSN parameters.
-- Persistent database mode: run during controlled initialization.
PRAGMA journal_mode = WAL;
-- Per connection: enforce relational correctness and bounded lock waiting.
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
-- Pick deliberately; see the durability contract above.
PRAGMA synchronous = NORMAL;- WAL improves read/write concurrency, but it still permits only one writer. Keep write transactions short.
- Do not enable WAL when the application relies on
ATTACH DATABASEunless the resulting behavior has been tested for that use case. - Treat
cache_size,mmap_size,temp_store=MEMORY,page_size, andjournal_size_limitas measured workload-specific tuning—not universal defaults. Cap memory use and consider 32-bit processes, containers, and I/O-error semantics before enabling large memory maps. - Configure a small, intentional Go connection limit. For write-heavy single-process services, one application-level write queue or one writer connection may be simpler and more predictable than retry storms. Allowing multiple read connections in WAL mode is appropriate only after measuring contention and confirming driver behavior.
- Use
PingContextafter initialization and fail startup if required PRAGMAs or the database path are unavailable. Completion: an integration test proves foreign-key violations fail on a fresh pooled connection.
3. Model Integrity in the Schema
- Use explicit types/affinities and
NOT NULL,CHECK,UNIQUE, andFOREIGN KEYconstraints for every durable invariant. Use transactions for multi-statement state transitions. - Prefer
INTEGER PRIMARY KEYfor a surrogate key when its semantics fit; it aliases SQLite's rowid and avoids a separate key index. - Use
WITHOUT ROWIDonly for tables with non-integer or composite primary keys after measuring its storage and query-plan effects. - Avoid
AUTOINCREMENTunless the requirement is never to reuse a deleted rowid; it adds overhead and does not mean "auto-generated ID" in the general sense. - Store money or precise quantities as scaled integers, not
REAL. Store timestamps in one documented canonical representation and validate ranges at the boundary. - Add secondary indexes only for demonstrated
WHERE, join,ORDER BY, orGROUP BYpaths. Each index consumes space and increases write cost. Completion: migrations include constraints and each new index names the query it serves.
4. Design Queries and Indexes From Real Plans
- Select only required columns and rows. Use
WHERE,LIMIT, keyset pagination, aggregates, joins, subqueries, and SQL-side filtering rather than loading excess data into Go. - Bind every value. Never construct SQL by concatenating untrusted input. Dynamic identifiers require a closed, code-owned allowlist; placeholders bind values, not identifiers.
- Use
QueryContext,QueryRowContext, andExecContext; close rows immediately after checking the query error and checkrows.Err()after iteration. - Reuse prepared statements for hot, repeated SQL when the installed driver benefits from it. Bound parameters also improve statement-cache reuse.
- Create composite indexes in query order: equality/
IN/ISconstrained left-most columns first, then the range column, then columns that can satisfy ordering or coverage. Index use generally stops after the first range inequality, and gaps or an unconstrained left-most column prevent normal prefix use. - Use
EXPLAIN QUERY PLANagainst realistic statistics and data. Investigate full scans, temporary B-trees for sort/group, and automatic indexes. An automatic index is a signal to evaluate a persistent index, not a blanket mandate to add one.
EXPLAIN QUERY PLAN
SELECT id, created_at
FROM orders
WHERE account_id = ? AND state = ? AND created_at >= ?
ORDER BY created_at DESC
LIMIT ?;- Run
PRAGMA optimize;at a controlled cadence (such as before closing a short-lived connection or during a quiet maintenance window for long-running services). Do not blindly run expensiveANALYZEorVACUUMin request paths. Completion: plan output explains which relevant index is used and why it is selective.
5. Control Transactions, Contention, and WAL
- Batch related writes in one short transaction; do not commit each row independently. Do not hold a transaction open across network calls, template rendering, user input, or slow computation.
- Handle
SQLITE_BUSYandSQLITE_LOCKEDaccording to an explicit policy: bounded busy timeout, limited retry with jitter only for idempotent work, and clear application-level errors when exhausted. Do not retry non-idempotent writes unless the transaction outcome is known. - Monitor WAL growth, checkpoint duration/failures, busy errors, query latency, database size, and free disk. Long-lived readers can prevent checkpoints from reclaiming WAL space.
- Keep automatic checkpointing unless measured tail latency justifies a controlled background checkpoint. Run
PRAGMA wal_checkpoint(FULL)orTRUNCATEonly in an operationally safe maintenance path: they can wait for readers/writers;TRUNCATEis disruptive. Set a measuredjournal_size_limitif bounded WAL disk use is required. - Run
VACUUMonly with a disk-space budget and maintenance plan; it rewrites the database and can block writes. Consider incremental auto-vacuum only when file-size reclamation is a recurring, measured need, and configure it when the database is created. Completion: load testing demonstrates the selected timeout, writer policy, and worst acceptable busy behavior.
6. Secure the Data and Operations Boundaries
External boundary
- Authenticate and authorize every request before it can read or mutate tenant or user data. Include ownership/tenant predicates in queries; parameterization does not solve broken access control.
- Validate and normalize all input, enforce page-size limits, and rate-limit expensive search/export endpoints. Use FTS5 for text search when appropriate; do not emulate broad text search with unbounded
%term%scans. - Protect browser state-changing flows with CSRF defenses and secure session cookies. SQLite is not a substitute for authorization.
Application boundary
- Use parameterized SQL exclusively. Keep SQL errors internal; return stable domain errors without paths, SQL text, or sensitive values.
- Do not enable arbitrary SQLite extensions or load extension paths from users/configuration. Treat SQL functions, virtual tables, triggers, and migrations as trusted code reviewed with the application.
- Use explicit transactions and constraints to prevent lost invariants. Keep sensitive data out of logs and tracing attributes.
Data and operations boundary
- Restrict the database directory and
-wal/-shmsidecar files to the service account. Do not serve the database directory as static content or expose it through file-download endpoints. - Keep encryption keys, backup credentials, and DSNs out of source control and logs. SQLite itself does not provide transparent database encryption; use an explicitly selected, supported encryption solution only when the threat model requires encryption at rest.
- Apply OS/disk encryption, least-privilege file ownership, and protected backups. Treat backups as production-sensitive data.
- Upgrade the SQLite library/Go driver on a deliberate cadence and audit advisories. Do not trust a system SQLite version without identifying the version actually linked by the driver.
7. Backups, Recovery, and Maintenance
- Back up with SQLite-aware tooling: the online backup API,
VACUUM INTO, or a coordinated snapshot procedure. Do not copy only the main database file while it is active in WAL mode; account for consistency and the WAL/SHM files. - Encrypt/protect backup storage, retain according to the recovery objective, and copy at least one backup off the primary host.
- Regularly restore into an isolated location and run
PRAGMA integrity_check;plus application smoke queries. A backup is not verified until restoration succeeds. - Alert on backup age/failure, restore-test failure, disk pressure, integrity failures, WAL growth, busy errors, and unusual latency. Completion: the recovery procedure has a dated successful restore test.
Go Review Checklist
- Driver and SQLite library versions are identified; DSN/connection initialization is driver-specific and tested.
- Database access uses contexts, parameter binding, closed rows,
rows.Err(), and explicitsql.ErrNoRowshandling. -
foreign_keys=ONis applied and tested for every connection. - Transactions are short; their failure and retry semantics are explicit.
- SQL invariants use constraints; authorization is enforced independently.
- Indexes are justified by representative
EXPLAIN QUERY PLANoutput and write cost. - WAL settings, checkpoints, and lock behavior are monitored and load-tested.
- Backups are encrypted/protected and restoration plus
integrity_checkis periodically verified.
Source Notes
This skill synthesizes the following supplied sources. SQLite's official optimizer documentation is authoritative for query-planner behavior; vendor and blog claims are treated as workload-dependent and require measurement.
- Android Developers — https://developer.android.com/topic/performance/sqlite-performance-best-practices
- PowerSync — https://powersync.com/blog/sqlite-optimizations-for-ultra-high-performance
- SQLite — https://www.sqlite.org/optoverview.html
- Philip Rösler — https://phiresky.github.io/blog/2020/sqlite-performance-tuning/
- OneUptime — https://oneuptime.com/blog/post/2026-02-02-sqlite-production-setup/view
- SQLite Forum — https://www.sqliteforum.com/p/sqlite-best-practices-review
Verification Procedure
- Inspect schema and migration SQL for constraints, indexes, and unsafe extensions. Completion: every invariant and index has a documented purpose.
- Run representative request and write workloads; capture latency, throughput,
SQLITE_BUSYcount, WAL size, checkpoint behavior, and storage. Completion: a before/after comparison identifies the environment and dataset. - Capture
EXPLAIN QUERY PLANfor changed and highest-cost queries. Completion: no unexplained scan, temporary sort, or automatic index remains on a latency-critical path. - Run integration tests on a fresh database and a newly opened pooled connection. Completion: foreign keys, transactions, cancellation, and error handling are exercised.
- Restore a current backup to an isolated path and run
PRAGMA integrity_check;. Completion: restore and integrity results are recorded without secrets.
Notes for this project (Predict Marketing)
This skill is general-purpose reference, kept verbatim from source above. ReadAGENTS.md §4a first for what this project's schema actually is before applying
any of the above.
An audit against this skill ran in round 29 and the findings are fixed. The
checklist should come back clean apart from the three items below that are
deliberately not met — contexts, a connection pool and backups. What the audit
changed, and what to leave alone:
foreign_keysis on, set in the DSN inopenDBso it reaches every
connection the driver opens rather than only the first.forecastshas always
declaredFOREIGN KEY (run_id) REFERENCES runs(id); SQLite defaults the pragma
off, so for a long time that reference was decorative. An earlier version of
this note claimed the project had no foreign keys at all. That was wrong.synchronousis left at the driver's default of FULL, deliberately. This
writes once per forecast and is not latency-bound, so there is nothing to buy
by weakening durability. Section 1 asks for the choice to be recorded: this is
the record.PRAGMA user_versiongates every open (checkSchema). Section 3's warning
aboutCREATE TABLE IF NOT EXISTSnot being a migration was not theoretical
here — the repo's ownpm.dbpredated per-entity storage, and every command
that touched it failed with a bareno such column: entity. A file whose shape
this build cannot read is now refused with an explanation.forecasts_medianis a partial index on(entity, run_id, metric, day) WHERE quantile = 0.5.forecast_accuracyendsWHERE f.quantile = 0.5, and
quantile is the last column of the primary key, so every query through the
view scanned the whole table. Section 4'sEXPLAIN QUERY PLANstep is what
found it. Measured on 2.3M rows: accuracy 1.6s → 12ms, filter validation
1.16s → 1ms. If a plan for a view query ever showsSCAN forecastsagain, that
index stopped being used.SetMaxOpenConns(1), not a connection pool — unchanged, and intentional.AGENTS.mdrecords why: this is a single-user CLI tool, and one connection
removes a whole class of contention this skill is written to manage. Section 2's
pooled-connection guidance does not apply unless that changes.- No contexts on queries, against Section 4's advice. There is no request
lifecycle and nothing to cancel in a CLI that runs one command and exits, soQueryContexteverywhere would be churn. Revisit only if this ever serves. - Backups are deliberately absent (Section 7). Everything in the database is
derived from the CSV exports and can be rebuilt by re-importing them, so the
exports are the thing worth backing up, not this file.
Non-finite values are worth knowing about, because the database is not the guard:
SQLite converts a NaN bind to NULL, which value REAL NOT NULL then rejects, but
it stores Infinity without complaint. parseCell in ingest.go is what keeps
both out, and sqlite_test.go pins that division of labour.