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.db
Open (or create) a database file.
.help
List all dot-commands.
.databases
Show 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.sql
Execute SQL from a file.
.shell cmd ...
Run a shell command without leaving sqlite3.
Output, import, export
.mode column | json | csv | markdown | box
Switch result formatting.
.headers on
Print column names with each result.
.timer on
Show wall-clock per query.
.output result.csv
Redirect query output to a file. .output stdout to reset.
.import --csv data.csv tbl
Bulk-load a CSV into a table.
.dump [tbl]
Emit schema + data as SQL (for backups or git diffs).
.backup backup.db
Atomic file-level backup — safe while writers are active.
.quit
Exit.
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.
INTEGER
1-, 2-, 4- or 8-byte signed int. INTEGER PRIMARY KEY aliases rowid.
REAL
IEEE-754 double precision.
TEXT
UTF-8 string.
BLOB
Raw bytes. Literal: x'48656c6c6f'.
NULL
Absence of value. Sorts first by default; use NULLS LAST to flip.
DECLARED VARCHAR(50) → TEXT
Affinity is by keyword match; CHAR/CLOB/VARCHAR all map to TEXT.
CREATE TABLE t (...) STRICT
Preferred Reject inserts whose values don’t match the declared type.
SELECT typeof(col) FROM t
Per-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 AUTOINCREMENT
Strictly monotonic across deletes. Costs an extra table; use only when needed.
CREATE TABLE t (a, b, PRIMARY KEY(a,b)) WITHOUT ROWID
Composite PK; saves one B-tree per row.
CREATE TABLE t (...) STRICT
Enforce declared types (3.37+).
FOREIGN KEY(uid) REFERENCES users(id) ON DELETE CASCADE
FK 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')) VIRTUAL
Computed on read; zero storage.
slug TEXT GENERATED ALWAYS AS (lower(title)) STORED
Computed at write; indexable like a normal column.
CREATE TABLE t2 AS SELECT * FROM t1
Copy 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 b
3.25+. Older fix: copy table.
ALTER TABLE t ADD COLUMN c TEXT DEFAULT 'x'
Always cheap — no rewrite.
DROP TABLE IF EXISTS t
Safe 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 = ON
Off by default for legacy compat. Set on every connection.
PRAGMA journal_mode = WAL
Write-ahead log. Concurrent readers + one writer.
PRAGMA synchronous = NORMAL
Pair with WAL. FULL is fsync-on-every-commit; rarely needed.
PRAGMA busy_timeout = 5000
Wait up to N ms on a busy lock before raising SQLITE_BUSY.
PRAGMA cache_size = -64000
Negative = KiB. -64000 = 64 MiB page cache.
PRAGMA temp_store = MEMORY
Temp tables / indexes in RAM, not on disk.
PRAGMA mmap_size = 268435456
Memory-map up to 256 MiB. Trades RSS for speed.
PRAGMA wal_autocheckpoint = 1000
Pages between auto-checkpoints. 0 disables.
PRAGMA optimize
Run before close to refresh sqlite_stat1.
PRAGMA integrity_check
Verify the file. Returns ok on a healthy DB.
PRAGMA user_version = 7
App-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 100
Pagination. Prefer keyset pagination for big offsets.
SELECT ... ORDER BY created DESC NULLS LAST
Explicit 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 q
Common table expression.
WITH RECURSIVE r(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM r WHERE n<10) SELECT n FROM r
Recursive CTE — series, trees, graph walks.
SELECT row_number() OVER (PARTITION BY uid ORDER BY ts DESC) AS rn FROM e
Window 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.name
Upsert. excluded.* refers to the row being inserted.
INSERT INTO t(a,b) VALUES (?,?) ON CONFLICT DO NOTHING
Idempotent 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 j
Unnest 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 = 0
Partial 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.
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 IMMEDIATE
Preferred for write txns. Reserves the lock up front; avoids mid-txn upgrade deadlocks.
BEGIN EXCLUSIVE
Blocks readers too. Rare.
COMMIT · ROLLBACK
Finalize the transaction.
SAVEPOINT s; ... RELEASE s; ROLLBACK TO s
Nested savepoints — partial rollback.
PRAGMA wal_checkpoint(TRUNCATE)
Force a checkpoint and shrink the -wal file.
PRAGMA locking_mode = EXCLUSIVE
Single-process. Skips file-lock dance; big speedup.
PRAGMA journal_size_limit = 67108864
Cap 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.
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).