SQLite in Production skill for Codex

WAL, short writes and backups that restore. Codex checks whether SQLite fits the deployment, then configures every connection path the same way: WAL, busy_timeout, synchronous NORMAL and foreign keys. It moves writers to BEGIN IMMEDIATE with short batched transactions, uses STRICT tables and the rebuild procedure for schema changes, and sets up .backup or Litestream with a tested restore. A bundled read-only script reports integrity, WAL size and unindexed foreign keys.

  • Databases
  • $sqlite-production
  • 173 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- sqlite-production

Installs to ~/.agents/skills/sqlite-production/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $sqlite-production, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Runs SQLite as a production database for web apps and services - WAL mode, per-connection pragmas (busy_timeout, synchronous, foreign_keys), BEGIN IMMEDIATE for writers, one-writer concurrency design, STRICT tables, safe online backups with .backup or VACUUM INTO, Litestream replication to S3-compatible storage, schema changes with the table-rebuild procedure, and a read-only health check script. Use when deploying SQLite behind Rails 8, Django, Go, Node or Python apps, when hitting "database is locked" errors, or when deciding whether SQLite fits a workload.

Use it when

  • Deploying a web app or service on SQLite
  • Seeing SQLITE_BUSY or database is locked under load
  • Setting up SQLite backups or Litestream replication
  • Deciding whether SQLite can handle a workload

Use something else when

  • You need SQLite replicas at the edge or embedded replicas: use libsql-turso
  • The app runs on Cloudflare Workers with D1: use cloudflare-d1

How to check the result

The bundled health script reports WAL mode with quick_check ok and no FK violations, a load test produces no locked errors, and a backup restores and passes integrity_check.

The mistake it prevents

Setting busy_timeout and foreign_keys once in a shell, not realizing they are per-connection and silently reset in the app and its cron jobs.

~/.agents/skills/sqlite-production/SKILL.md

--- name: sqlite-production description: Runs SQLite as a production database for web apps and services - WAL mode, per-connection pragmas (busy_timeout, synchronous, foreign_keys), BEGIN… license: MIT ---

SQLite serves real production traffic when it runs on one host’s local disk, in WAL mode, with every connection configured the same way, writes kept short and serialized, and continuous off-host backups. Done well means no database is locked errors under load, a measured restore path, and an honest answer to “does this need a server database instead?”

Does SQLite fit?

SituationVerdict
One app server (or one primary process), local SSDGood fit
Read-heavy, writes up to hundreds per second, each shortGood fit
Several app servers writing to the same databaseNo. Use Postgres, or an edge/replicated variant ($libsql-turso, $cloudflare-d1)
Database file on NFS, SMB, EFS or other network storageNo. Locking and WAL shared memory break; corruption risk
Long write transactions (bulk jobs) mixed with latency-sensitive writesOnly with batching; every write waits for the single writer
Need for row-level permissions, many DB users, or remote SQL accessNo

Before changing anything

  1. Read AGENTS.md and find where connections are opened (ORM config, a db.py, a Go sql.Open). Every code path that opens the file must apply the same pragmas, including background jobs, migrations and one-off scripts.
  2. Run the health check on a copy or on the live file (it opens read-only):
python3 scripts/sqlite_health.py data/app.db          # quick_check
python3 scripts/sqlite_health.py data/app.db --full   # integrity_check, slower

It reports journal mode, size, free pages, WAL size, non-STRICT tables, unindexed foreign keys and FK violations, and exits 1 on integrity or FK failures.

Connection setup

Persistent (stored in the file, set once): journal_mode=WAL, page_size, auto_vacuum. Per-connection (must be set every time a connection opens): everything else.

PRAGMA journal_mode = WAL;        -- persistent; readers no longer block the writer
PRAGMA busy_timeout = 5000;       -- wait up to 5 s for a lock instead of failing
PRAGMA synchronous = NORMAL;      -- safe with WAL; FULL only if every commit must survive power loss
PRAGMA foreign_keys = ON;         -- OFF by default, per connection
PRAGMA cache_size = -64000;       -- ~64 MB page cache (negative = KiB)
PRAGMA temp_store = MEMORY;

Run PRAGMA optimize; periodically or when long-lived connections close (it is cheap and updates statistics only where useful).

Framework hooks:

  • Rails 8: ships production-ready SQLite defaults (WAL, busy handling, immediate transactions). Check config/database.yml for timeout and any pragmas: overrides.
  • Django 5.1+: OPTIONS = {"transaction_mode": "IMMEDIATE", "init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;"} (verify init_command handles multiple statements on your version, or use a connection_created signal).
  • Python sqlite3: sqlite3.connect(path, timeout=5.0, isolation_level="IMMEDIATE") makes implicit transactions start with BEGIN IMMEDIATE.
  • Go: the driver DSN carries pragmas (syntax differs between modernc.org/sqlite and mattn/go-sqlite3; check its README). Use a separate *sql.DB for writes with SetMaxOpenConns(1), and a larger pool for reads.
  • Node: better-sqlite3 is synchronous and fast; call db.pragma('journal_mode = WAL') and friends right after opening.

Writes and “database is locked”

SQLite allows one writer at a time. With WAL, readers never block the writer and the writer never blocks readers. Locked errors come from:

CauseFix
No busy_timeout, so contention fails instantlySet it on every connection
Deferred transaction reads, then tries to write after another writer committed (SQLITE_BUSY even with a timeout)Start write transactions with BEGIN IMMEDIATE
Long write transactions (bulk import, network calls inside a transaction)Batch into short transactions (1k-10k rows), never hold a transaction across I/O
Many goroutines/threads/processes writing at onceFunnel writes through one connection or a queue
A forgotten open transaction in a REPL or admin toolFind and close it; fuser/lsof on the file shows holders

Bulk inserts: wrap thousands of inserts in one transaction and reuse a prepared statement. Single-row autocommit inserts are 100x slower because each commit syncs.

Checkpoints and file growth

  • The WAL is merged back into the main file by checkpoints (auto at ~1000 pages). A reader that never finishes (a long report, a leaked read transaction) prevents the WAL from resetting, and it grows without bound. Keep reads short; monitor WAL size.
  • PRAGMA journal_size_limit = 67108864; caps the WAL size left on disk after checkpoints.
  • Deleted data leaves free pages; the file does not shrink. VACUUM rebuilds it but needs exclusive access and up to 2x disk space: schedule it with the user’s approval.

Schema and types

  • Use STRICT tables (SQLite 3.37+) so a text value cannot land in an INTEGER column: CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL) STRICT;
  • INTEGER PRIMARY KEY aliases the rowid (fast). Add AUTOINCREMENT only if ids must never be reused.
  • Store timestamps as ISO-8601 text in UTC or integer Unix epochs; pick one per database.
  • Index foreign key columns yourself.
  • ALTER TABLE supports add column, rename column/table and DROP COLUMN (3.35+). Other changes (types, constraints) need the documented rebuild: create new table, copy, drop old, rename, recreate indexes and triggers, inside one transaction with PRAGMA foreign_keys=OFF set before it and PRAGMA foreign_key_check after.
  • Track schema version with PRAGMA user_version if the project has no migration tool.

Backups and replication

Never copy the database file with cp while the app runs: the WAL holds committed data and the copy can be inconsistent.

sqlite3 data/app.db ".backup '/backups/app-$(date -u +%Y%m%dT%H%M%SZ).db'"
sqlite3 data/app.db "VACUUM INTO '/backups/app-compact.db'"     # compacted copy

Python: src.backup(sqlite3.connect(dest)). Verify every backup with python3 scripts/sqlite_health.py <backup> --full.

Continuous replication with Litestream streams WAL changes to S3-compatible storage (needs network and credentials). Minimal /etc/litestream.yml:

dbs:
  - path: /data/app.db
    replicas:
      - url: s3://acme-backups/app
litestream replicate -config /etc/litestream.yml
litestream restore -config /etc/litestream.yml -o /tmp/restore.db /data/app.db

Litestream 0.5 changed its storage format; check the installed version’s docs for config keys and do not mix versions on one replica path. Run a restore drill and the health script on the result before calling backups done. Restoring over the live database file is destructive: stop the app, keep the old file, and confirm with the user first.

Gotchas

  • Docker: mount the database directory as a volume on local disk, include the -wal and -shm files’ directory, and run one container that writes.
  • foreign_keys and busy_timeout silently revert to defaults on any connection that did not set them, such as an admin shell or a cron script.
  • Opening the same file through two different SQLite libraries in one process (two drivers) can break POSIX locks; use one.
  • Time-based DEFAULT CURRENT_TIMESTAMP is UTC text without a zone suffix; document it.

Verification

  • Health script: WAL mode, quick_check ok, no FK violations
  • Every connection path sets busy_timeout, foreign_keys, synchronous
  • Writers use BEGIN IMMEDIATE and short transactions; load test shows no locked errors
  • Backups taken with .backup/VACUUM INTO or Litestream; a restore was tested
  • Database on local disk, single writing host

Output template

## SQLite production review: <app>
**Fit:** <yes/no and why: hosts, write rate, storage>
**Pragmas:** <persistent and per-connection, where set in code>
**Write path:** <transaction mode, batching, writer pool>
**Backups:** <method, frequency, destination, last restore test>
**Health check:** <script output summary>
**Risks:** <growth, long readers, migration constraints>

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "SQLite in Production"
  short_description: "WAL, short writes and backups that restore"
  default_prompt: "$sqlite-production We keep getting database is locked errors from SQLite in production, fix it"
scripts/sqlite_health.py108 lines, 4.2 KB
#!/usr/bin/env python3
"""Read-only health report for a SQLite database file.

Usage: python3 sqlite_health.py <path/to/db.sqlite> [--full]

Opens the file read-only (no writes, no checkpoint), reports persistent settings,
file and WAL sizes, free pages, foreign key violations, tables without STRICT or
without indexes on foreign key columns, and runs quick_check (or integrity_check
with --full). Exits 1 if integrity or foreign key checks fail, 2 on usage errors.

Per-connection pragmas (busy_timeout, synchronous, foreign_keys) are NOT stored in
the file, so this script cannot see what the application sets; check the app code.
"""
import os
import sqlite3
import sys


def human(n: int) -> str:
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if n < 1024:
            return f"{n:.0f} {unit}"
        n /= 1024
    return f"{n:.1f} PB"


def main() -> int:
    args = [a for a in sys.argv[1:] if not a.startswith("--")]
    full = "--full" in sys.argv
    if len(args) != 1:
        print(__doc__.strip().splitlines()[2], file=sys.stderr)
        return 2
    path = args[0]
    if not os.path.isfile(path):
        print(f"error: {path} not found", file=sys.stderr)
        return 2

    uri = f"file:{os.path.abspath(path)}?mode=ro"
    con = sqlite3.connect(uri, uri=True)
    q = lambda sql: con.execute(sql).fetchall()
    one = lambda sql: q(sql)[0][0]

    problems, warnings = [], []
    print(f"SQLite library    {sqlite3.sqlite_version}")
    print(f"file              {path} ({human(os.path.getsize(path))})")
    wal = path + "-wal"
    if os.path.exists(wal):
        size = os.path.getsize(wal)
        print(f"wal file          {human(size)}")
        if size > 256 * 1024 * 1024:
            warnings.append("WAL file over 256 MB: checkpoints may be starved by long readers")

    journal = one("PRAGMA journal_mode")
    page_size = one("PRAGMA page_size")
    pages = one("PRAGMA page_count")
    free = one("PRAGMA freelist_count")
    auto_vac = {0: "none", 1: "full", 2: "incremental"}.get(one("PRAGMA auto_vacuum"), "?")
    print(f"journal_mode      {journal}")
    print(f"page_size         {page_size}")
    print(f"pages             {pages} ({human(pages * page_size)})")
    print(f"free pages        {free} ({free * 100 / max(pages, 1):.1f}%)")
    print(f"auto_vacuum       {auto_vac}")
    print(f"user_version      {one('PRAGMA user_version')}")
    if journal.lower() != "wal":
        warnings.append(f"journal_mode is {journal}, not wal: readers and the writer block each other")
    if pages and free / pages > 0.25:
        warnings.append("over 25% free pages: consider VACUUM (needs exclusive access and 2x disk)")

    tables = [r[0] for r in q(
        "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")]
    print(f"tables            {len(tables)}")
    for (name, sql) in q("SELECT name, sql FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'"):
        if sql and "STRICT" not in sql.upper().rsplit(")", 1)[-1]:
            warnings.append(f"table {name} is not STRICT (type affinity accepts mismatched values)")
    for t in tables:
        fks = q(f'PRAGMA foreign_key_list("{t}")')
        if not fks:
            continue
        indexed_first_cols = set()
        for idx in q(f'PRAGMA index_list("{t}")'):
            cols = q(f'PRAGMA index_info("{idx[1]}")')
            if cols:
                indexed_first_cols.add(cols[0][2])
        for fk in fks:
            if fk[3] not in indexed_first_cols:
                warnings.append(f"{t}.{fk[3]} references {fk[2]} but has no index")

    violations = q("PRAGMA foreign_key_check")
    if violations:
        problems.append(f"{len(violations)} foreign key violation(s), first: {violations[0]}")

    check = "integrity_check" if full else "quick_check"
    result = [r[0] for r in q(f"PRAGMA {check}")]
    print(f"{check:<17} {'ok' if result == ['ok'] else 'FAILED'}")
    if result != ["ok"]:
        problems.extend(result[:10])

    for w in warnings:
        print(f"warn: {w}")
    for p in problems:
        print(f"FAIL: {p}")
    print("result            " + ("FAIL" if problems else "ok"))
    con.close()
    return 1 if problems else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem