DS DevShelfHub Projects · AI tools
Cheatsheets / SQLite
Cheatsheet · Dev tooling

SQLite: Pragmas, STRICT Tables, JSON1, FTS5 and WAL Reference Guide

By DevShelfHub

Pragmas, dot-commands, STRICT tables, JSON1, FTS5, WAL, the Python sqlite3 driver — the single-file DB used everywhere from iOS to Airbus.

114 items 8 min File-based WAL FTS5

Start hereQuick start · 6 you’ll reach for daily

Open a DBsqlite3 app.db
Run a filesqlite3 app.db ".read schema.sql"
Show schema.schema users
UpsertINSERT ... ON CONFLICT DO UPDATE
Enable WALPRAGMA journal_mode=WAL;
Plan a queryEXPLAIN QUERY PLAN SELECT ...

Target versions · paceVersions

Targets: sqlite ≥ 3.45 FTS5 + JSON1 (built-in) python ≥ 3.10

SQLite is the most-deployed database on Earth and ships in the Python, iOS, Android, and macOS stdlibs. Releases are slow and additive — STRICT tables (3.37), RETURNING (3.35), ->> JSON operator (3.38), unixepoch() (3.38). Many distros lag — check SELECT sqlite_version(); before relying on a feature.

Install · connectSetup

bash
# macOS — Homebrew (modern sqlite ≥ 3.45)
brew install sqlite

# Linux — Debian/Ubuntu
sudo apt install sqlite3

# Show version + compile-time options (FTS5, JSON1 should be present)
sqlite3 --version
sqlite3 :memory: "PRAGMA compile_options;" | head

# Open or create a DB file (no separate server, no users)
sqlite3 app.db

# Run a SQL file against a DB without entering the shell
sqlite3 app.db < schema.sql
sqlite3 app.db ".read schema.sql"

# One-shot query, machine-readable output
sqlite3 -json app.db "SELECT id, name FROM users LIMIT 5;"

# Python — stdlib only, no install
python -c "import sqlite3; print(sqlite3.sqlite_version)"

The shellsqlite3 · dot-commands

Open, inspect, run

sqlite3 app.dbOpen (or create) a database file.
.helpList all dot-commands.
.databasesShow attached database files.
.tables [pattern]List tables matching a glob.
.schema [name]Show the CREATE statement(s).
.indexes [tbl]List indexes (on a table, if given).
.read script.sqlExecute SQL from a file.
.shell cmd ...Run a shell command without leaving sqlite3.

Output, import, export

.mode column | json | csv | markdown | boxSwitch result formatting.
.headers onPrint column names with each result.
.timer onShow wall-clock per query.
.output result.csvRedirect query output to a file. .output stdout to reset.
.import --csv data.csv tblBulk-load a CSV into a table.
.dump [tbl]Emit schema + data as SQL (for backups or git diffs).
.backup backup.dbAtomic file-level backup — safe while writers are active.
.quitExit.

Storage classes · affinityData types

SQLite has 5 storage classes, not column types. Declared types map to an affinity (preferred class). STRICT tables (3.37+) enforce types per-column.

INTEGER1-, 2-, 4- or 8-byte signed int. INTEGER PRIMARY KEY aliases rowid.
REALIEEE-754 double precision.
TEXTUTF-8 string.
BLOBRaw bytes. Literal: x'48656c6c6f'.
NULLAbsence of value. Sorts first by default; use NULLS LAST to flip.
DECLARED VARCHAR(50) → TEXTAffinity is by keyword match; CHAR/CLOB/VARCHAR all map to TEXT.
CREATE TABLE t (...) STRICTPreferred Reject inserts whose values don’t match the declared type.
SELECT typeof(col) FROM tPer-row storage class — useful in non-STRICT tables.

Tables · constraintsDDL

Tables & keys

CREATE TABLE t (id INTEGER PRIMARY KEY, ...)Cheapest auto-incrementing key. Alias of rowid.
id INTEGER PRIMARY KEY AUTOINCREMENTStrictly monotonic across deletes. Costs an extra table; use only when needed.
CREATE TABLE t (a, b, PRIMARY KEY(a,b)) WITHOUT ROWIDComposite PK; saves one B-tree per row.
CREATE TABLE t (...) STRICTEnforce declared types (3.37+).
FOREIGN KEY(uid) REFERENCES users(id) ON DELETE CASCADEFK action — requires PRAGMA foreign_keys=ON.
CHECK(price > 0), UNIQUE(email)Inline constraints.
created TEXT DEFAULT (datetime('now'))Wrap function defaults in parentheses.

Generated columns & alters

email TEXT GENERATED ALWAYS AS (json_extract(data,'$.email')) VIRTUALComputed on read; zero storage.
slug TEXT GENERATED ALWAYS AS (lower(title)) STOREDComputed at write; indexable like a normal column.
CREATE TABLE t2 AS SELECT * FROM t1Copy schema + data; does not copy indexes or constraints.
CREATE VIEW v AS SELECT ...Saved query. Read-only unless backed by triggers.
ALTER TABLE t RENAME COLUMN a TO b3.25+. Older fix: copy table.
ALTER TABLE t ADD COLUMN c TEXT DEFAULT 'x'Always cheap — no rewrite.
DROP TABLE IF EXISTS tSafe idempotent drop.

Per-connection settingsPragmas

Pragmas tune behaviour per connection. The big ones below are the difference between “works on my laptop” and a production-ready setup.

PRAGMA foreign_keys = ONOff by default for legacy compat. Set on every connection.
PRAGMA journal_mode = WALWrite-ahead log. Concurrent readers + one writer.
PRAGMA synchronous = NORMALPair with WAL. FULL is fsync-on-every-commit; rarely needed.
PRAGMA busy_timeout = 5000Wait up to N ms on a busy lock before raising SQLITE_BUSY.
PRAGMA cache_size = -64000Negative = KiB. -64000 = 64 MiB page cache.
PRAGMA temp_store = MEMORYTemp tables / indexes in RAM, not on disk.
PRAGMA mmap_size = 268435456Memory-map up to 256 MiB. Trades RSS for speed.
PRAGMA wal_autocheckpoint = 1000Pages between auto-checkpoints. 0 disables.
PRAGMA optimizeRun before close to refresh sqlite_stat1.
PRAGMA integrity_checkVerify the file. Returns ok on a healthy DB.
PRAGMA user_version = 7App-defined schema version int. No extra table needed.
sql
-- Recommended pragmas for a server-side app (run on every connection)
PRAGMA journal_mode = WAL;          -- concurrent readers + one writer
PRAGMA synchronous  = NORMAL;       -- safe with WAL, much faster than FULL
PRAGMA foreign_keys = ON;           -- off by default — must enable per-connection
PRAGMA busy_timeout = 5000;         -- wait 5s on SQLITE_BUSY instead of erroring
PRAGMA cache_size   = -64000;       -- negative = KiB → 64 MiB page cache
PRAGMA temp_store   = MEMORY;       -- keep temp tables in RAM
PRAGMA mmap_size    = 268435456;    -- memory-map up to 256 MiB of the db file

-- Maintenance — run periodically (e.g. nightly)
PRAGMA optimize;                    -- rebuild stats based on recent queries
PRAGMA wal_checkpoint(TRUNCATE);    -- force checkpoint and shrink the -wal file
PRAGMA integrity_check;             -- ok / corruption report

-- App-defined schema version (no separate table needed)
PRAGMA user_version = 7;
SELECT user_version FROM pragma_user_version;

Select · insert · upsertQueries & DML

Reading

SELECT ... FROM t LIMIT 50 OFFSET 100Pagination. Prefer keyset pagination for big offsets.
SELECT ... ORDER BY created DESC NULLS LASTExplicit NULL ordering.
SELECT a, b FROM t WHERE id IN (1,2,3)IN with a literal list.
SELECT * FROM t WHERE col GLOB 'a*'Case-sensitive glob; LIKE is the case-insensitive variant.
WITH q AS (SELECT ...) SELECT * FROM qCommon table expression.
WITH RECURSIVE r(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM r WHERE n<10) SELECT n FROM rRecursive CTE — series, trees, graph walks.
SELECT row_number() OVER (PARTITION BY uid ORDER BY ts DESC) AS rn FROM eWindow functions (3.25+).

Writing

INSERT INTO t(a,b) VALUES (?, ?)Always use parameters; never f-strings.
INSERT INTO t(email,name) VALUES (?,?) ON CONFLICT(email) DO UPDATE SET name=excluded.nameUpsert. excluded.* refers to the row being inserted.
INSERT INTO t(a,b) VALUES (?,?) ON CONFLICT DO NOTHINGIdempotent insert; silently skip dupes.
UPDATE t SET col = col + 1 WHERE id = ?In-place mutation.
DELETE FROM t WHERE created < date('now','-30 day')Date arithmetic via modifiers.
INSERT INTO t(...) VALUES (...) RETURNING *3.35+. Avoid a second round-trip.
VALUES (1,'a'),(2,'b')Inline row set — useful in joins / CTEs.

JSON1 extensionJSON

json('{"a":1}')Validate and canonicalize a JSON string.
json_extract(d, '$.a')Read value at a JSONPath. Returns TEXT for objects/arrays.
d -> '$.a'3.38+. Same as json_extract but returns JSON.
d ->> '$.a'3.38+. Returns the scalar (TEXT / INT / REAL).
json_array_length(d, '$.tags')Length of a JSON array.
json_set(d,'$.a',1), json_remove(d,'$.a')Return a modified copy. SQLite is immutable — UPDATE with the result.
SELECT t.id, j.value FROM t, json_each(t.tags) AS jUnnest a JSON array into rows.
SELECT * FROM t, json_tree(t.data) WHERE type='string'Recursive walk over the JSON tree.
CREATE INDEX i_email ON users(json_extract(data,'$.email'))Expression index — query planner uses it when you call the same expression.

Full-text searchFTS5

CREATE VIRTUAL TABLE docs USING fts5(title, body)Stand-alone FTS table. FTS owns the data.
CREATE VIRTUAL TABLE docs USING fts5(title, body, content='docs_raw', content_rowid='id')Contentless / external-content mode — keep canonical data in docs_raw.
USING fts5(..., tokenize='porter unicode61')Porter stemmer + Unicode normalization. Strong default for English.
SELECT * FROM docs WHERE docs MATCH '"exact phrase"'Phrase query.
... MATCH 'redis AND ttl', 'red*', 'redis NEAR/3 cache'Boolean, prefix, proximity operators.
ORDER BY bm25(docs)Lower = more relevant. Default ranker.
highlight(docs, 0, '<b>', '</b>')Wrap matched terms in a column.
snippet(docs, 1, '<b>','</b>','…', 32)Surround the match with up to 32 tokens of context.
INSERT INTO docs(docs) VALUES('optimize')Merge FTS segments after bulk loads.
sql
-- Standalone FTS5 table (FTS owns the data)
CREATE VIRTUAL TABLE docs USING fts5(
    title,
    body,
    tokenize = 'porter unicode61 remove_diacritics 2'
);

INSERT INTO docs(title, body) VALUES
  ('Redis caching', 'Use redis to cache hot keys with TTL.'),
  ('Postgres indexes', 'B-tree and GIN are the workhorses.');

-- Phrase, boolean, prefix, NEAR
SELECT rowid, title FROM docs WHERE docs MATCH '"hot keys"';
SELECT rowid, title FROM docs WHERE docs MATCH 'redis AND ttl';
SELECT rowid, title FROM docs WHERE docs MATCH 'red*';
SELECT rowid, title FROM docs WHERE docs MATCH 'redis NEAR/3 cache';

-- Ranking + highlight + snippet
SELECT rowid,
       highlight(docs, 0, '', '') AS hl_title,
       snippet(docs, 1, '', '', '…', 24) AS hl_body,
       bm25(docs) AS score
FROM docs
WHERE docs MATCH ?
ORDER BY score
LIMIT 10;

-- Optimize internal segments after bulk loads
INSERT INTO docs(docs) VALUES ('optimize');

Plans · statsIndexes & EXPLAIN

CREATE INDEX idx_t_col ON t(col)Single-column B-tree.
CREATE UNIQUE INDEX idx_t_ab ON t(a, b)Composite unique; leftmost-prefix rules apply.
CREATE INDEX idx_active ON t(col) WHERE deleted = 0Partial index. Smaller, faster, only used when planner sees the same predicate.
CREATE INDEX idx_lower_email ON users(lower(email))Expression index. Queries must call the same expression to hit it.
REINDEX [name]Rebuild after collation changes or corruption.
ANALYZE [name]Refresh sqlite_stat1 stats for the planner.
EXPLAIN QUERY PLAN SELECT ...Read SEARCH vs SCAN — SCAN means full-table.
EXPLAIN SELECT ...Opcode dump. Rarely useful unless debugging the VM.

Stdlib driverPython · sqlite3

import sqlite3; con = sqlite3.connect("app.db")Open / create. ":memory:" for ephemeral.
sqlite3.connect("app.db", detect_types=sqlite3.PARSE_DECLTYPES)Map declared types to Python (e.g. DATEdate).
con.row_factory = sqlite3.RowDict-like rows. Access by name or index.
con.execute("SELECT * FROM t WHERE id=?", (id,))Always ?. Never build SQL with f-strings.
con.executemany(sql, rows)Batch insert / update. Wrap in a txn for huge speedup.
cur.fetchone() · fetchall() · fetchmany(n)Pull rows. Iterate the cursor for streaming.
with con: con.execute(...)Context manager = transaction. Auto-commits on exit, rolls back on exception.
con.executescript("...")Run multiple statements separated by ;.
con = sqlite3.connect(path, check_same_thread=False)Share across threads — you own the locking.
con.create_function("slugify", 1, slugify_py)Register a Python function as a SQL function.
con.set_trace_callback(print)Log every executed SQL — handy in dev.
python
import sqlite3
from contextlib import closing

def connect(path: str) -> sqlite3.Connection:
    con = sqlite3.connect(path, isolation_level=None)  # autocommit; manage txns explicitly
    con.row_factory = sqlite3.Row                       # rows behave like dicts
    con.execute("PRAGMA journal_mode=WAL")
    con.execute("PRAGMA foreign_keys=ON")
    con.execute("PRAGMA busy_timeout=5000")
    return con

with closing(connect("app.db")) as con, con:           # outer `with con` = transaction
    con.execute("""
        CREATE TABLE IF NOT EXISTS users (
            id    INTEGER PRIMARY KEY,
            email TEXT NOT NULL UNIQUE,
            name  TEXT
        )
    """)
    # Parameterized — always use ?, never f-strings
    con.executemany(
        "INSERT INTO users(email, name) VALUES (?, ?) "
        "ON CONFLICT(email) DO UPDATE SET name=excluded.name",
        [("a@x.io", "Ada"), ("b@x.io", "Bob")],
    )
    row = con.execute(
        "SELECT id, name FROM users WHERE email = ?", ("a@x.io",)
    ).fetchone()
    print(dict(row))                                    # {'id': 1, 'name': 'Ada'}

Locks · WAL · checkpointsTransactions & WAL

BEGIN [DEFERRED]Default. Locks acquired lazily on first write.
BEGIN IMMEDIATEPreferred for write txns. Reserves the lock up front; avoids mid-txn upgrade deadlocks.
BEGIN EXCLUSIVEBlocks readers too. Rare.
COMMIT · ROLLBACKFinalize the transaction.
SAVEPOINT s; ... RELEASE s; ROLLBACK TO sNested savepoints — partial rollback.
PRAGMA wal_checkpoint(TRUNCATE)Force a checkpoint and shrink the -wal file.
PRAGMA locking_mode = EXCLUSIVESingle-process. Skips file-lock dance; big speedup.
PRAGMA journal_size_limit = 67108864Cap WAL growth (bytes). Stops a runaway -wal.

Full pipeline · ~25 linesEnd-to-end · TODO with FTS5

Creates a todos table, an external-content FTS5 index, and a trigger that keeps the search index in sync on insert. Inserts one row and runs a ranked search.

python
import sqlite3, json

con = sqlite3.connect("todo.db", isolation_level=None)
con.row_factory = sqlite3.Row
con.executescript("""
    PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;

    CREATE TABLE IF NOT EXISTS todos (
        id       INTEGER PRIMARY KEY,
        title    TEXT NOT NULL,
        body     TEXT,
        done     INTEGER NOT NULL DEFAULT 0,
        created  TEXT NOT NULL DEFAULT (datetime('now'))
    );

    CREATE VIRTUAL TABLE IF NOT EXISTS todos_fts USING fts5(
        title, body, content='todos', content_rowid='id'
    );

    CREATE TRIGGER IF NOT EXISTS todos_ai AFTER INSERT ON todos BEGIN
        INSERT INTO todos_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
    END;
""")

with con:
    con.execute("INSERT INTO todos(title, body) VALUES (?, ?)",
                ("Ship cheatsheet", "Write SQLite quick reference with FTS5 example."))

hits = con.execute("""
    SELECT t.id, t.title, snippet(todos_fts, 1, '[', ']', '…', 12) AS hit
    FROM todos_fts JOIN todos t ON t.id = todos_fts.rowid
    WHERE todos_fts MATCH ?
    ORDER BY bm25(todos_fts)
""", ("fts5 OR cheatsheet",)).fetchall()
print(json.dumps([dict(r) for r in hits], indent=2))

Best practiceGood to know

Turn on WAL once, in every production deployment. journal_mode=WAL + synchronous=NORMAL + busy_timeout=5000 turns SQLite from “single-file toy” into a real concurrent store for read-heavy apps.
Wrap bulk inserts in one transaction. A naive loop runs ~1k inserts/sec because each statement is its own txn (fsync per row). Wrapping executemany in a single BEGIN ... COMMIT typically jumps to 50k+/sec.
Use STRICT + CHECK instead of trusting affinity. Without STRICT, SQLite happily stores "three" in an INTEGER column. STRICT tables refuse, and CHECK(typeof(x)='integer') retrofits the same for legacy schemas.

Common trapsWatch out for

Foreign keys are off by default. PRAGMA foreign_keys=ON is per-connection. ORM’s and migration tools often issue it; raw sqlite3 calls do not. Silent dangling refs are the result.
One writer at a time, even in WAL mode. Concurrent writes get SQLITE_BUSY. Set busy_timeout, keep write txns short, and use BEGIN IMMEDIATE to fail fast.
Never build SQL with string formatting. f"... WHERE id={uid}" is the canonical SQL-injection mistake. Always pass values as parameters (? or :name) — the driver escapes and type-checks for you.

Go deeperSee also

SQLite FAQ

What is SQLite and when should I use it?

SQLite is a serverless, embedded SQL database stored as a single file. Use it for desktop apps, mobile apps (iOS and Android use it by default), CLI tools, testing, and any workload with a single writer and moderate data size. It is not suitable for high-concurrency web applications — use PostgreSQL or MySQL for multi-user server workloads.

What is WAL mode in SQLite and why should I enable it?

Write-Ahead Logging (WAL) mode (PRAGMA journal_mode=WAL) allows concurrent reads and writes without blocking. In the default rollback journal mode, a write locks all readers. WAL mode dramatically improves performance for web applications with many concurrent readers and occasional writes. Enable it on every SQLite database used outside of single-process scripts.

What are SQLite pragmas?

Pragmas are SQLite-specific commands that query or set database properties. Key ones: PRAGMA journal_mode=WAL for concurrency, PRAGMA foreign_keys=ON to enforce FK constraints (off by default), PRAGMA cache_size=-65536 to set a 64 MB cache, and PRAGMA synchronous=NORMAL to trade a little crash-safety for speed. Apply them at the start of each connection.

Does SQLite support JSON?

Yes. The JSON1 extension (built in since SQLite 3.38) provides json(), json_extract(), json_object(), json_array(), json_each(), and json_tree() functions. Store JSON as TEXT and extract fields with json_extract(data, "$.key"). You can index extracted fields with expression indexes: CREATE INDEX idx ON t(json_extract(data, "$.user_id")).

How does SQLite full-text search work?

Create an FTS5 virtual table: CREATE VIRTUAL TABLE docs USING fts5(title, body). Insert rows normally. Query with the MATCH operator: SELECT * FROM docs WHERE docs MATCH "python AND async". FTS5 supports phrase queries, prefix queries, column filters, and BM25 ranking via the rank column. Use FTS5 (not FTS4) for new projects.

How do I use SQLite in Python?

The sqlite3 module is in the Python standard library — no install needed. Open with conn = sqlite3.connect("db.sqlite3"), create a cursor with cur = conn.cursor(), and execute with cur.execute("SELECT ..."). Use conn.row_factory = sqlite3.Row for dict-like row access. Always call conn.commit() after writes or use the connection as a context manager (with sqlite3.connect(...) as conn).