Databases No. 212

SQLite in Production skill for Claude Code

One file, real pragmas, no more database is locked. Claude configures SQLite like a server database: WAL mode, busy_timeout and foreign_keys on every connection, BEGIN IMMEDIATE for writes so SQLITE_BUSY becomes a short wait, STRICT tables, and user_version migrations with the official table-rebuild procedure. A bundled stdlib Python script reports integrity, FK violations, WAL growth and unindexed foreign keys, and backups use VACUUM INTO or Litestream.

  • namesqlite-local-first
  • size213 lines · ≈2,450 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Runs SQLite as a production database for servers, desktop, and local-first apps - WAL mode, per-connection pragmas (busy_timeout, synchronous, foreign_keys), single-writer concurrency with BEGIN IMMEDIATE, STRICT tables, user_version migrations and the table-rebuild procedure, backups with VACUUM INTO, and replication with Litestream or libSQL/Turso. Use when choosing SQLite for production, seeing "database is locked" or SQLITE_BUSY errors, configuring better-sqlite3 or Python sqlite3, writing SQLite migrations, or setting up SQLite backup and replication.

Try asking

  • We get 'database is locked' errors under load with better-sqlite3 - fix it
  • Set up SQLite for our single-server app with continuous backups to S3
  • Write a migration that changes a column type in our SQLite table

Use it when

  • You are running SQLite behind a web server, desktop app, or edge function
  • Writes fail with SQLITE_BUSY or database is locked
  • You need SQLite migrations beyond simple ADD COLUMN
  • You want streaming backups or read replicas for a SQLite file

Skip it when

How to check the result

Query PRAGMA foreign_keys and busy_timeout from the app's own connection, run a parallel writer test with no SQLITE_BUSY errors, and confirm sqlite_health.py exits 0 on a restored backup.

The common mistake it prevents

Starting write transactions with a plain deferred BEGIN makes lock upgrades fail instantly with SQLITE_BUSY, and busy_timeout cannot retry them.

~/.claude/skills/sqlite-local-first/SKILL.md

--- name: sqlite-local-first description: Runs SQLite as a production database for servers, desktop, and local-first apps - WAL mode, per-connection pragmas (busy_timeout,… license: MIT ---

SQLite is production-ready when every connection applies the same pragmas, writes go through short BEGIN IMMEDIATE transactions so lock contention turns into brief waits instead of errors, the database lives on a local disk (never a network filesystem), and there is a continuously tested backup or replica. Configure it deliberately; the defaults are tuned for backward compatibility, not for servers.

When SQLite fits

FitsDoes not fit
Single server or single process owning the file (app server, CLI, desktop, mobile, edge with embedded replicas)Many app servers writing the same DB over the network
Write throughput up to roughly hundreds to low thousands of small transactions/secSustained heavy concurrent writes from many processes
Read-heavy workloads; reads scale with cores in WAL modeNeed for row-level locking across long write transactions
Data fits on one machine’s diskMulti-region active-active writes (consider a server DB or a CRDT sync layer)

Connection setup (every connection, every time)

journal_mode=WAL persists in the file; everything else below is per-connection and must be set on each new connection (pool hooks, ORM afterConnect, etc.).

PRAGMA journal_mode = WAL;          -- once per database; persistent
PRAGMA busy_timeout = 5000;         -- wait up to 5 s for locks instead of failing immediately
PRAGMA synchronous = NORMAL;        -- safe with WAL: no corruption; may lose last commits on power loss
PRAGMA foreign_keys = ON;           -- OFF by default, per connection
PRAGMA cache_size = -64000;         -- negative = KiB (here ~64 MB) per connection
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 268435456;       -- optional: 256 MB memory-mapped reads
PRAGMA journal_size_limit = 67108864; -- cap WAL file size after checkpoints (64 MB)
  • Use synchronous = FULL if losing the last few committed transactions on power loss is unacceptable (payments, ledgers).
  • Long-lived connections: run PRAGMA optimize; periodically (e.g. hourly) and before closing; SQLite 3.46+ also documents PRAGMA optimize=0x10002; right after opening.

Driver specifics:

// better-sqlite3 (Node, synchronous API)
import Database from "better-sqlite3";
const db = new Database("app.db");
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 5000");
db.pragma("synchronous = NORMAL");
db.pragma("foreign_keys = ON");
const transfer = db.transaction((from, to, amt) => { /* statements */ });
transfer.immediate(1, 2, 500);      // BEGIN IMMEDIATE variant
# Python 3.12+: take explicit control of transactions
import sqlite3
con = sqlite3.connect("app.db", autocommit=True, timeout=5.0)
for p in ("journal_mode=WAL", "synchronous=NORMAL", "foreign_keys=ON"):
    con.execute(f"PRAGMA {p}")
con.execute("BEGIN IMMEDIATE")
try:
    con.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", (500, 1))
    con.execute("COMMIT")
except Exception:
    con.execute("ROLLBACK")
    raise

On Python before 3.12, use isolation_level=None for the same explicit control; the legacy default silently opens deferred transactions before DML.

Concurrency model

  • WAL: unlimited concurrent readers, exactly one writer at a time. Readers do not block the writer and vice versa.
  • BEGIN (deferred) starts as a reader and upgrades to writer on the first write. If another connection wrote in the meantime, the upgrade fails with SQLITE_BUSY immediately; busy_timeout cannot help. Start every transaction that will write with BEGIN IMMEDIATE.
  • Keep write transactions short (milliseconds). Never hold one across network calls, user input, or slow computation.
  • Funnel writes through one connection (or a single writer queue/thread) in high-write apps; use a pool of read-only connections for reads.
  • Batch many small writes into one transaction: 10,000 inserts in one transaction can be orders of magnitude faster than 10,000 autocommits.

“database is locked” triage:

CauseFix
No busy_timeout on some connectionSet it on every connection
Deferred transaction upgraded to writeBEGIN IMMEDIATE
Long write transactionShorten; move work outside the transaction
Forgotten open transaction / unfinalized statementClose cursors, reset statements, check driver transaction mode
DB on NFS/SMB/cloud-synced folderMove to local disk
Not in WAL modePRAGMA journal_mode=WAL;

Schema

  • Use STRICT tables (3.37+) so types are enforced: CREATE TABLE ... (...) STRICT; Without it, SQLite accepts 'abc' in an INTEGER column.
  • INTEGER PRIMARY KEY aliases the rowid (fast). Add AUTOINCREMENT only if IDs must never be reused; it costs extra writes.
  • Store timestamps as ISO-8601 TEXT in UTC ('2026-10-19T12:00:00Z') or INTEGER Unix epoch; pick one per project.
  • Index foreign key columns; enable foreign_keys or the constraints are decorative.
  • Booleans are integers 0/1; add CHECK (flag IN (0, 1)).
  • JSON: store as TEXT (or JSONB blob format via jsonb() on 3.45+) and index extracted values with expression indexes: CREATE INDEX ix ON events (json_extract(data, '$.type'));

Migrations

Track the schema version with PRAGMA user_version (or the migration tool’s table):

BEGIN IMMEDIATE;
-- migration 7
ALTER TABLE users ADD COLUMN locale TEXT NOT NULL DEFAULT 'en';
PRAGMA user_version = 7;
COMMIT;

ALTER TABLE supports ADD COLUMN (with restrictions: no non-constant default, no PRIMARY KEY/UNIQUE), RENAME COLUMN, RENAME TO, and DROP COLUMN (3.35+, fails if the column is indexed, part of a constraint, or referenced). Anything else - changing a type, adding a constraint, changing a default - needs the table rebuild:

PRAGMA foreign_keys = OFF;            -- must be outside a transaction
BEGIN IMMEDIATE;
CREATE TABLE users_new (
  id    INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  name  TEXT NOT NULL DEFAULT ''
) STRICT;
INSERT INTO users_new (id, email, name) SELECT id, email, coalesce(name, '') FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
-- recreate indexes, triggers, and views that referenced users
PRAGMA foreign_key_check;             -- must return no rows before committing
COMMIT;
PRAGMA foreign_keys = ON;

Take a backup before any rebuild; DROP TABLE is destructive and needs user confirmation on real data.

Local-first/mobile apps: migrations run on every client device at app start. Make them idempotent, fast, and tested against databases created by every released app version.

Backups and replication

NeedTool
Consistent snapshot of a live DBVACUUM INTO '/backups/app-2026-10-19.db'; or sqlite3 app.db ".backup /backups/app.db"
Continuous streaming to object storage, point-in-time restoreLitestream
Read replicas / edge copies synced from a primarylibSQL embedded replicas (Turso)
Mobile/desktop local-first syncA sync engine (CRDT or server-authoritative sync); see offline-sync
  • Never copy the .db file with cp while it is in use; the -wal file holds committed data not yet in the main file. Use VACUUM INTO or the backup API.
  • Litestream runs as a sidecar process (litestream replicate -config litestream.yml) shipping WAL changes to S3-compatible storage; restore with litestream restore -o restored.db <replica-url>. Config details changed in the 0.5 release (2025); follow the docs for the installed version.
  • Only one process should run Litestream for a given database, and the app must not checkpoint in ways that bypass it (keep the default WAL auto-checkpointing unless the docs for your version say otherwise).
  • Test restores on a schedule: restore, run PRAGMA integrity_check;, and query recent rows.

Health check script

python3 scripts/sqlite_health.py path/to/app.db          # quick_check
python3 scripts/sqlite_health.py path/to/app.db --full   # integrity_check (slower)

Reports journal mode, size, free pages, WAL size, integrity and foreign-key violations, unindexed FK columns, non-STRICT tables, and tables without a primary key. Exits 1 on integrity or FK errors. It opens the file read-only.

Gotchas

  • WAL does not work on network filesystems, and -wal/-shm files must stay next to the database (container volumes must include the directory, not just the file).
  • A long-running read transaction prevents checkpoints from completing, so the WAL grows without bound (“checkpoint starvation”). Close read transactions promptly.
  • PRAGMA foreign_keys = ON inside a transaction is a silent no-op.
  • ALTER TABLE ... ADD COLUMN ... NOT NULL requires a non-NULL default.
  • LIKE is case-insensitive for ASCII only; = is case-sensitive unless COLLATE NOCASE.
  • Integer overflow: SQLite integers are 64-bit, but JavaScript numbers lose precision above 2^53; enable BigInt mode in the driver (better-sqlite3 safeIntegers()) if IDs can exceed that.
  • Deleting data does not shrink the file; VACUUM rewrites the whole DB (needs free disk equal to its size and blocks writers) - schedule it, or use auto_vacuum = INCREMENTAL set before tables are created.

Verification

  1. Every connection path applies the pragmas: query PRAGMA foreign_keys; PRAGMA busy_timeout; from the app’s own connection, not a fresh shell.
  2. Concurrency test: run N parallel writers and readers for a minute; no SQLITE_BUSY errors surface to callers.
  3. python3 scripts/sqlite_health.py app.db exits 0.
  4. Restore drill from the backup or Litestream replica succeeds and passes integrity_check.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/sqlite_health.py 160 lines · 5.7 KB
#!/usr/bin/env python3
"""Health report for a SQLite database file (stdlib only).

Usage:
    python3 scripts/sqlite_health.py path/to/app.db [--full]

Checks (read-only connection):
  - persistent settings: journal_mode, page_size, auto_vacuum, user_version
  - size, free pages, WAL file size
  - PRAGMA quick_check (or integrity_check with --full)
  - PRAGMA foreign_key_check
  - foreign key columns without a supporting index
  - tables that are not STRICT, tables without an explicit primary key

Exit code 0 when no errors were found, 1 when integrity or FK violations exist,
2 on usage errors. Warnings do not change the exit code.
"""
from __future__ import annotations

import os
import sqlite3
import sys


def human(n: float) -> str:
    for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
        if n < 1024:
            return f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} PiB"


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

    uri = f"file:{os.path.abspath(path)}?mode=ro"
    con = sqlite3.connect(uri, uri=True)
    con.row_factory = sqlite3.Row
    errors: list[str] = []
    warnings: list[str] = []

    def pragma(name: str):
        return con.execute(f"PRAGMA {name}").fetchone()[0]

    print(f"SQLite library: {sqlite3.sqlite_version}")
    print(f"Database: {path}")

    journal = pragma("journal_mode")
    page_size = pragma("page_size")
    page_count = pragma("page_count")
    freelist = pragma("freelist_count")
    auto_vacuum = {0: "none", 1: "full", 2: "incremental"}.get(pragma("auto_vacuum"), "?")
    user_version = pragma("user_version")
    db_size = page_size * page_count

    print("\n== Settings ==")
    print(f"journal_mode   {journal}")
    print(f"page_size      {page_size}")
    print(f"auto_vacuum    {auto_vacuum}")
    print(f"user_version   {user_version}  (schema version if migrations use it)")

    print("\n== Size ==")
    print(f"database       {human(db_size)} ({page_count} pages)")
    free_pct = 100.0 * freelist / page_count if page_count else 0.0
    print(f"free pages     {freelist} ({free_pct:.1f}%)")
    wal_path = path + "-wal"
    if os.path.exists(wal_path):
        wal_size = os.path.getsize(wal_path)
        print(f"WAL file       {human(wal_size)}")
        if db_size and wal_size > max(64 * 1024 * 1024, db_size):
            warnings.append(
                "WAL is larger than the database (or 64 MiB): checkpoints may be starved "
                "by long-lived readers; check for open read transactions."
            )

    if journal.lower() != "wal":
        warnings.append("journal_mode is not WAL; concurrent readers will block on writes.")
    if free_pct > 25:
        warnings.append("More than 25% free pages; consider VACUUM or incremental auto_vacuum.")

    print("\n== Integrity ==")
    check = "integrity_check" if full else "quick_check"
    rows = [r[0] for r in con.execute(f"PRAGMA {check}").fetchall()]
    if rows == ["ok"]:
        print(f"{check}: ok")
    else:
        for r in rows[:20]:
            print(f"{check}: {r}")
        errors.append(f"{check} reported {len(rows)} problem(s)")

    fk_rows = con.execute("PRAGMA foreign_key_check").fetchall()
    if fk_rows:
        for r in fk_rows[:20]:
            print(f"foreign_key_check: table={r[0]} rowid={r[1]} parent={r[2]}")
        errors.append(f"foreign_key_check found {len(fk_rows)} violation(s)")
    else:
        print("foreign_key_check: ok")

    print("\n== Schema ==")
    tables = con.execute(
        "SELECT name, sql FROM sqlite_schema WHERE type = 'table' "
        "AND name NOT LIKE 'sqlite_%' ORDER BY name"
    ).fetchall()
    strict_supported = sqlite3.sqlite_version_info >= (3, 37, 0)
    non_strict: list[str] = []
    for t in tables:
        name = t["name"]
        q = name.replace('"', '""')
        if strict_supported:
            info = con.execute(
                "SELECT strict, wr FROM pragma_table_list WHERE name = ?", (name,)
            ).fetchone()
            if info is not None and not info["strict"]:
                non_strict.append(name)
        cols = con.execute(f'PRAGMA table_info("{q}")').fetchall()
        if not any(c["pk"] for c in cols):
            warnings.append(f"table {name} has no explicit PRIMARY KEY")

        indexed_leading: set[str] = set()
        for idx in con.execute(f'PRAGMA index_list("{q}")').fetchall():
            iq = idx["name"].replace('"', '""')
            first = con.execute(f'PRAGMA index_info("{iq}")').fetchone()
            if first is not None and first["name"] is not None:
                indexed_leading.add(first["name"])
        pk_cols = [c["name"] for c in sorted(cols, key=lambda c: c["pk"]) if c["pk"]]
        if pk_cols:
            indexed_leading.add(pk_cols[0])
        for fk in con.execute(f'PRAGMA foreign_key_list("{q}")').fetchall():
            if fk["seq"] == 0 and fk["from"] not in indexed_leading:
                warnings.append(
                    f"FK {name}.{fk['from']} -> {fk['table']} has no index; "
                    "parent deletes/updates and joins will scan the table"
                )

    print(f"tables         {len(tables)}")
    if strict_supported:
        print(f"non-STRICT     {len(non_strict)}" + (f" ({', '.join(non_strict[:10])})" if non_strict else ""))

    con.close()

    print("\n== Result ==")
    for w in warnings:
        print(f"WARN  {w}")
    for e in errors:
        print(f"ERROR {e}")
    if not warnings and not errors:
        print("OK    no issues found")
    return 1 if errors else 0


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

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc