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.
- name
sqlite-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
- Many servers must write the same database concurrently - use postgres-schema-design
- You need conflict resolution for device sync - use offline-sync
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.
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
| Fits | Does 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/sec | Sustained heavy concurrent writes from many processes |
| Read-heavy workloads; reads scale with cores in WAL mode | Need for row-level locking across long write transactions |
| Data fits on one machine’s disk | Multi-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 = FULLif 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 documentsPRAGMA 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 withSQLITE_BUSYimmediately;busy_timeoutcannot help. Start every transaction that will write withBEGIN 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:
| Cause | Fix |
|---|---|
No busy_timeout on some connection | Set it on every connection |
| Deferred transaction upgraded to write | BEGIN IMMEDIATE |
| Long write transaction | Shorten; move work outside the transaction |
| Forgotten open transaction / unfinalized statement | Close cursors, reset statements, check driver transaction mode |
| DB on NFS/SMB/cloud-synced folder | Move to local disk |
| Not in WAL mode | PRAGMA journal_mode=WAL; |
Schema
- Use
STRICTtables (3.37+) so types are enforced:CREATE TABLE ... (...) STRICT;Without it, SQLite accepts'abc'in an INTEGER column. INTEGER PRIMARY KEYaliases the rowid (fast). AddAUTOINCREMENTonly 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_keysor 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
| Need | Tool |
|---|---|
| Consistent snapshot of a live DB | VACUUM INTO '/backups/app-2026-10-19.db'; or sqlite3 app.db ".backup /backups/app.db" |
| Continuous streaming to object storage, point-in-time restore | Litestream |
| Read replicas / edge copies synced from a primary | libSQL embedded replicas (Turso) |
| Mobile/desktop local-first sync | A sync engine (CRDT or server-authoritative sync); see offline-sync |
- Never copy the
.dbfile withcpwhile it is in use; the-walfile holds committed data not yet in the main file. UseVACUUM INTOor the backup API. - Litestream runs as a sidecar process (
litestream replicate -config litestream.yml) shipping WAL changes to S3-compatible storage; restore withlitestream 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/-shmfiles 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 = ONinside a transaction is a silent no-op.ALTER TABLE ... ADD COLUMN ... NOT NULLrequires a non-NULL default.LIKEis case-insensitive for ASCII only;=is case-sensitive unlessCOLLATE 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;
VACUUMrewrites the whole DB (needs free disk equal to its size and blocks writers) - schedule it, or useauto_vacuum = INCREMENTALset before tables are created.
Verification
- Every connection path applies the pragmas: query
PRAGMA foreign_keys; PRAGMA busy_timeout;from the app’s own connection, not a fresh shell. - Concurrency test: run N parallel writers and readers for a minute; no
SQLITE_BUSYerrors surface to callers. python3 scripts/sqlite_health.py app.dbexits 0.- 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
#!/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.