SQLite
Use SQLite well: the sqlite3 shell, type affinity, WAL mode and pragmas, locking, JSON and FTS5, backups, tuning, and safe access from Go and Python.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Open or create a database | sqlite3 app.db |
| Run one statement from the shell | sqlite3 app.db 'SELECT count(*) FROM users;' |
| List tables, indexes | .tables, .indexes users |
| Show DDL | .schema users, .schema for everything |
| Readable output | .mode box (also column, table, markdown, json, csv, line) |
| Headers on | .headers on |
| Import a CSV with a header row | .import --csv users.csv users |
| Export a query as CSV | .mode csv, .once out.csv, then the SELECT |
| Dump SQL text | .dump or sqlite3 app.db .dump > app.sql |
| Consistent online backup | sqlite3 app.db ".backup 'app-backup.db'" |
| Copy compacted | VACUUM INTO 'app-compact.db'; |
| Turn on WAL (persistent) | PRAGMA journal_mode = WAL; |
| Wait for locks instead of failing | PRAGMA busy_timeout = 5000; |
| Enforce foreign keys (per connection) | PRAGMA foreign_keys = ON; |
| Query plan | EXPLAIN QUERY PLAN SELECT ...; or .eqp on |
| Time each statement | .timer on |
| Check integrity | PRAGMA integrity_check; (quick_check for a fast pass) |
| Refresh planner statistics | ANALYZE; or PRAGMA optimize; at connection close |
| Recover a damaged file | sqlite3 broken.db .recover | sqlite3 recovered.db |
| Columns of a table | PRAGMA table_info(users); or SELECT * FROM pragma_table_info('users'); |
| Compile options and version | SELECT sqlite_version();, PRAGMA compile_options; |
Behaviour below is SQLite 3.45 or later; the sqlite3 shell on Fedora and RHEL 9 is new enough for everything here except where a version is given. Reference: sqlite.org/docs.html.
What SQLite is and is not#
SQLite is a library, not a server. Every process that opens the file is a full database engine; coordination happens through POSIX file locks on the database file, so there is no network protocol, no user accounts, no connection limit beyond file handles, and no background process to keep alive. The whole database, including its schema, is one file (app.db) plus, in WAL mode, app.db-wal and app.db-shm while a connection is open. That design makes it the right choice for an application’s local state, an embedded configuration store, a CLI’s cache, test fixtures and analytical work on files up to tens of gigabytes, and the wrong choice for many writers on different machines, which needs PostgreSQL or MySQL.
Writes are serialised: exactly one connection can write at a time, however many read. Throughput on a modern SSD is tens of thousands of small transactions per second in WAL mode with synchronous=NORMAL, and a single transaction can insert millions of rows per second, so “SQLite is slow” is nearly always one transaction per row or a missing index. Network filesystems (NFS, SMB, many FUSE mounts) do not implement the locking SQLite relies on; the documentation says not to use them, and corruption is the eventual result.
The sqlite3 shell#
sqlite3 app.db # interactive; creates the file on first write
sqlite3 -readonly app.db # refuse writes
sqlite3 -header -column app.db 'SELECT * FROM users LIMIT 5'
sqlite3 -json app.db 'SELECT id, name FROM users' | jq '.[0]'
sqlite3 -csv -header app.db 'SELECT * FROM users' > users.csv
sqlite3 app.db < schema.sql # run a script; add -bail to stop at the first error
sqlite3 :memory: 'SELECT sqlite_version()' # in-memory scratch databaseDot commands are shell features, not SQL: they take no trailing semicolon and are not available to your application.
.tables -- also .tables 'user%'
.schema users -- CREATE statements; .schema --indent for formatting
.fullschema -- includes the sqlite_stat tables
.indexes users
.mode box -- box, table, column, markdown, json, csv, tabs, line, insert, quote
.headers on
.width 8 30 0 -- fixed column widths in column mode; 0 is auto
.nullvalue NULL -- render NULL visibly
.timer on -- CPU and wall time per statement
.eqp on -- print EXPLAIN QUERY PLAN before each statement; .eqp full includes the bytecode
.changes on -- print rows changed after each statement
.import --csv users.csv users -- creates the table with TEXT columns if it does not exist; otherwise columns must match
.import --csv --skip 1 users.csv users -- skip the header when the table already exists
.output report.txt -- redirect until .output stdout; .once writes only the next statement's output
.dump users -- SQL text for one table; .dump alone for the whole database
.read migration.sql
.backup app-backup.db -- online backup through the backup API: consistent while others write
.restore app-backup.db -- overwrite the open database from a backup
.recover -- best-effort SQL from a corrupted file
.dbinfo -- page size, page count, freelist, schema cookie
.databases -- attached databases and their files
.parameter set :id 42 -- bind a parameter for the following statements
.shell ls -l -- run a shell command
.exit.import treats every field as text and inserts into whatever columns the table has, in order; type affinity converts '42' to an integer for an INTEGER column but leaves 'N/A' as text. Import into a staging table and INSERT INTO ... SELECT with casts and validation when the CSV is untrusted. .dump | sqlite3 new.db is a portable, version-independent copy, and the fastest way to move a database between hosts with different SQLite builds.
Types and affinity#
SQLite stores values, not columns, with a type: NULL, INTEGER (1 to 8 bytes), REAL, TEXT or BLOB. A column’s declared type only sets an affinity that decides how an inserted value is converted, and a column declared INTEGER will store the text 'abc' unless the table is STRICT. Any declared type name maps to one of five affinities: contains INT gives INTEGER; CHAR, CLOB or TEXT gives TEXT; BLOB or no type gives BLOB; REAL, FLOA or DOUB gives REAL; anything else (including DATE, DATETIME, BOOLEAN, DECIMAL) gives NUMERIC.
CREATE TABLE users (
id INTEGER PRIMARY KEY, -- alias for the 64-bit rowid: fastest key, reused after delete unless AUTOINCREMENT
email TEXT NOT NULL UNIQUE COLLATE NOCASE, -- case-insensitive uniqueness and lookup
name TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), -- there is no boolean type; TRUE and FALSE are 1 and 0
balance INTEGER NOT NULL DEFAULT 0, -- cents; REAL is binary floating point
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), -- ISO-8601 UTC text sorts correctly
settings TEXT CHECK (settings IS NULL OR json_valid(settings))
) STRICT; -- 3.37+: rejects values that do not fit the declared type
CREATE TABLE tags (name TEXT PRIMARY KEY, colour TEXT) WITHOUT ROWID; -- clustered on the key; good for small-key lookup tables
SELECT typeof(id), typeof(email), typeof(balance) FROM users LIMIT 1; -- integer text integer
SELECT '42' = 42, 42 = 42.0, 'a' < 1, x'00' > 'z'; -- 0 1 0 1: NULL < INTEGER/REAL < TEXT < BLOB in comparisonsComparisons between a text column and a numeric literal apply the column’s affinity to the literal, so WHERE id = '42' still uses the index on an INTEGER column; the reverse (WHERE text_col = 42) does not. Integer division truncates: 7 / 2 is 3, 7 / 2.0 is 3.5. STRICT tables allow only INT, INTEGER, REAL, TEXT, BLOB and ANY as declared types. INTEGER PRIMARY KEY AUTOINCREMENT prevents reuse of deleted ids at the cost of a write to sqlite_sequence per insert; use it only when ids are exposed and reuse would confuse something. Foreign keys are parsed but not enforced unless PRAGMA foreign_keys = ON is run on every connection.
WAL mode and pragmas#
The default rollback journal copies original pages to -journal before writing, and a writer blocks readers while it commits. Write-ahead logging appends changed pages to -wal instead and periodically checkpoints them back into the main file. Readers see the last committed state without blocking the writer and the writer does not wait for readers, which is the concurrency model a web application or a multi-goroutine service needs. WAL is stored in the file header, so setting it once persists for every future connection.
PRAGMA journal_mode = WAL; -- returns 'wal' on success; needs no other connection to hold a lock
PRAGMA synchronous = NORMAL; -- per connection: fsync at checkpoint, not every commit; durable against process crash, may lose the last transactions on power loss
PRAGMA busy_timeout = 5000; -- per connection: retry a locked database for up to 5 s instead of SQLITE_BUSY immediately
PRAGMA foreign_keys = ON; -- per connection
PRAGMA cache_size = -65536; -- per connection: 64 MiB of page cache (negative is KiB, positive is pages)
PRAGMA temp_store = MEMORY; -- sorts and temp tables in RAM
PRAGMA mmap_size = 268435456; -- read pages through a 256 MiB memory map; skips a copy
PRAGMA wal_autocheckpoint = 1000; -- default: checkpoint when the WAL passes 1000 pages (about 4 MB)
PRAGMA wal_checkpoint(TRUNCATE); -- force a checkpoint and reset the WAL to zero bytes; blocks until readers finish
PRAGMA journal_size_limit = 67108864; -- cap the WAL file at 64 MiB after checkpoint
PRAGMA auto_vacuum = INCREMENTAL; -- must be set before any table exists, or run VACUUM after changing it
PRAGMA page_size = 4096; -- default since 3.12; only changes with VACUUM
PRAGMA secure_delete = ON; -- overwrite deleted content; slower
PRAGMA journal_mode; -- query any pragma by omitting the valuePragmas without a note above persist in the file (journal_mode, page_size, auto_vacuum, user_version, application_id). The rest are per connection and belong in the connection-open code of the application, not in a one-off shell session. PRAGMA user_version is an integer slot the application owns and is the conventional place for a schema migration number.
A WAL database has three files while open. Copying only app.db while a process holds it open loses the un-checkpointed transactions in app.db-wal, and the -shm file is a shared-memory index that is safe to delete only when no connection is open. WAL needs write access to the directory to create the two side files, so a read-only mount or a directory the process cannot write makes even SELECT fail with attempt to write a readonly database; open with ?immutable=1 for a file that will never change.
Transactions and locking#
Autocommit wraps every statement in its own transaction, with an fsync per statement in rollback mode. Wrap batches in one transaction: a loop of 100,000 single-row inserts takes minutes in autocommit and well under a second inside BEGIN ... COMMIT.
BEGIN; -- DEFERRED: takes no lock until the first read or write
BEGIN IMMEDIATE; -- take the write lock now; fails fast with SQLITE_BUSY or waits busy_timeout
BEGIN EXCLUSIVE; -- in WAL mode the same as IMMEDIATE; in rollback mode also blocks readers
INSERT INTO users (email, name) VALUES ('a@example.com', 'A');
SAVEPOINT s1; UPDATE users SET active = 0 WHERE id = 1; ROLLBACK TO s1; RELEASE s1;
COMMIT;Use BEGIN IMMEDIATE for any transaction that will write. A DEFERRED transaction that reads first and then writes must upgrade its shared lock to a write lock; if another writer committed in between, SQLite returns SQLITE_BUSY_SNAPSHOT immediately and does not consult busy_timeout, because retrying would violate the snapshot the reads already used. Starting IMMEDIATE moves the wait to the beginning where the timeout applies.
WAL readers never block and are never blocked, but a long-lived read transaction (a cursor left open, an ORM session that never commits) pins the WAL: checkpoints cannot recycle pages older than the oldest reader, and the WAL grows without bound. PRAGMA wal_checkpoint(PASSIVE) reports busy, log and checkpointed page counts; a log far above checkpointed with a small database points at such a reader. The isolation level is always serialisable within one file; there is no dirty read and no READ COMMITTED.
JSON#
The JSON functions are built in since 3.38 (earlier builds needed the JSON1 extension). JSON is stored as text, or since 3.45 as the internal JSONB binary format that skips parsing on each access; jsonb(...) converts, and every function accepts either.
INSERT INTO users (email, name, settings) VALUES ('b@example.com', 'B', '{"theme":"dark","tags":["ops","db"],"limits":{"rows":500}}');
SELECT settings ->> 'theme', -- 'dark' (->> returns SQL text/number; -> returns JSON text with quotes)
settings -> 'limits' ->> 'rows', -- 500
json_extract(settings, '$.tags[0]'), -- 'ops'
json_array_length(settings, '$.tags'), -- 2
json_type(settings, '$.limits') -- 'object'
FROM users WHERE email = 'b@example.com';
UPDATE users SET settings = json_set(settings, '$.theme', 'light', '$.limits.rows', 1000); -- set or insert
UPDATE users SET settings = json_insert(settings, '$.beta', true); -- insert only if absent
UPDATE users SET settings = json_remove(settings, '$.tags[1]');
UPDATE users SET settings = json_patch(settings, '{"limits":{"rows":null}}'); -- RFC 7396 merge patch: null deletes
SELECT u.email, t.value FROM users u, json_each(u.settings, '$.tags') t; -- one row per array element
SELECT key, value, type, fullkey FROM json_tree('{"a":{"b":[1,2]}}'); -- recursive walk
SELECT json_group_array(json_object('id', id, 'email', email)) FROM users WHERE active = 1; -- rows to a JSON array
SELECT json_group_object(email, name) FROM users;
CREATE INDEX users_theme ON users (settings ->> 'theme'); -- expression index; the query must use the same expression
SELECT * FROM users WHERE settings ->> 'theme' = 'dark';
CREATE TABLE events (id INTEGER PRIMARY KEY, body TEXT, kind TEXT GENERATED ALWAYS AS (body ->> 'kind') VIRTUAL); -- 3.31+: index the generated columnjson_extract returns SQL NULL for a missing path and the JSON null also as SQL NULL; json_type(x, '$.k') distinguishes them ('null' versus NULL). Path syntax is $, .key, [n] and [#-1] for the last element; keys with dots or spaces are quoted: '$."my.key"'.
Full-text search with FTS5#
FTS5 is a virtual table module that builds an inverted index over text columns and answers MATCH queries with ranking. Most builds include it (PRAGMA compile_options lists ENABLE_FTS5). Use an external-content table so the text is stored once and the FTS index only holds tokens.
CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT, body TEXT, published_at TEXT);
CREATE VIRTUAL TABLE articles_fts USING fts5(
title, body,
content = 'articles', content_rowid = 'id', -- external content: read title and body from articles
tokenize = 'porter unicode61 remove_diacritics 2' -- stemming, Unicode case folding, accent-insensitive
);
-- keep the index in sync
CREATE TRIGGER articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
INSERT INTO articles_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;
INSERT INTO articles_fts(articles_fts) VALUES ('rebuild'); -- index existing rows once
SELECT a.id, a.title,
bm25(articles_fts) AS score, -- lower is better
snippet(articles_fts, 1, '<b>', '</b>', '...', 12) AS excerpt, -- column 1 = body, 12 tokens
highlight(articles_fts, 0, '<b>', '</b>') AS title_hl
FROM articles_fts
JOIN articles a ON a.id = articles_fts.rowid
WHERE articles_fts MATCH 'wal AND (checkpoint OR "write ahead")'
ORDER BY score
LIMIT 20;
SELECT rowid FROM articles_fts WHERE articles_fts MATCH 'title : sqlite'; -- restrict to a column
SELECT rowid FROM articles_fts WHERE articles_fts MATCH 'check*'; -- prefix
SELECT rowid FROM articles_fts WHERE articles_fts MATCH 'NEAR(wal checkpoint, 5)';
SELECT rowid FROM articles_fts WHERE articles_fts MATCH 'sqlite NOT mysql';
INSERT INTO articles_fts(articles_fts) VALUES ('optimize'); -- merge b-tree segments after bulk loads
INSERT INTO articles_fts(articles_fts, rank) VALUES ('rank', 'bm25(10.0, 1.0)'); -- weight title 10x body for ORDER BY rankQuery syntax treats bare words as implicit AND, supports OR, NOT, phrases in double quotes, ^ for column-start, and column : term. User input containing quotes or operators causes a syntax error; wrap each user token in double quotes (doubling any embedded quotes) before building the expression. With content='' (contentless) the table cannot return snippet() and cannot 'delete' by content unless declared contentless_delete=1 (3.43+).
Backup, restore and vacuum#
Copying the database file with cp is safe only when no process has it open. For a live database use the backup API, VACUUM INTO, or .dump, all of which read a consistent snapshot without blocking writers in WAL mode.
sqlite3 app.db ".backup 'backups/app-$(date +%F).db'" # page-level copy, same size and settings as the source
sqlite3 app.db "VACUUM INTO 'backups/app-$(date +%F).db'" # 3.27+: compacted copy without free pages
sqlite3 app.db .dump | gzip > "backups/app-$(date +%F).sql.gz" # portable SQL text; slowest, smallest
gunzip -c backups/app-2026-09-24.sql.gz | sqlite3 restored.db # restore from SQL
sqlite3 restored.db 'PRAGMA integrity_check; PRAGMA journal_mode;' # WAL setting is carried by .backup, not by .dumpVACUUM rebuilds the database into a new file and swaps it in, reclaiming pages freed by deletes (SQLite never shrinks the file otherwise), defragmenting tables and applying a changed page_size or auto_vacuum. It needs free space for a full copy, takes an exclusive lock for the duration and rewrites everything, so schedule it, do not run it after every delete. PRAGMA auto_vacuum = INCREMENTAL plus periodic PRAGMA incremental_vacuum(200) trims the file gradually. PRAGMA freelist_count says how many pages a vacuum would recover.
Restoring over a WAL database
Replacing app.db while a stale app.db-wal sits next to it applies the old WAL to the new file on the next open and corrupts it. Stop every process, remove app.db-wal and app.db-shm, then copy the file in.
Performance#
EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'a@example.com';
-- SEARCH users USING INDEX sqlite_autoindex_users_1 (email=?) good
-- SCAN users full table scan
-- USE TEMP B-TREE FOR ORDER BY sort not served by an index
-- SEARCH ... USING COVERING INDEX no table lookup needed
ANALYZE; -- gather statistics into sqlite_stat1; rerun after large changes
PRAGMA optimize; -- 3.18+: run ANALYZE only where useful; call before closing long-lived connections
CREATE INDEX orders_user_created ON orders (user_id, created_at DESC); -- leftmost prefix rule as in every B-tree
CREATE INDEX orders_open ON orders (user_id) WHERE closed_at IS NULL; -- partial index
CREATE INDEX users_email_lower ON users (lower(email)); -- expression indexThe rules that move the needle, roughly in order: batch writes into transactions; use prepared statements and bind parameters rather than string formatting (parsing SQL costs more than executing it for small statements); WAL with synchronous=NORMAL; indexes for every WHERE and JOIN column the plan scans; INTEGER PRIMARY KEY rather than a text UUID as the rowid, or WITHOUT ROWID when a text key is unavoidable; a larger cache_size and mmap_size for read-heavy workloads; PRAGMA optimize on close. For bulk loads, create indexes after the inserts, and consider PRAGMA synchronous = OFF and journal_mode = OFF for a throwaway import where a crash means starting again. The bytecode EXPLAIN (without QUERY PLAN) is rarely needed; EXPLAIN QUERY PLAN and .timer on answer almost every question.
Connection pooling matters differently from a server database. Opening a connection is cheap but not free (it reads the schema), and each connection has its own page cache, so a service keeps a small pool. Because only one writer runs at a time, a pool of writers just queues on the lock; the common pattern is one connection dedicated to writes and a pool for reads.
From Go#
modernc.org/sqlite is a pure-Go translation of SQLite that needs no cgo and cross-compiles cleanly; github.com/mattn/go-sqlite3 wraps the C library through cgo and is somewhat faster. Both register with database/sql. Set the connection pragmas in the DSN so every pooled connection gets them.
package main
import (
"context"
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
func open(path string) (*sql.DB, error) {
// _pragma applies per connection; journal_mode persists in the file but is harmless to repeat.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)&_txlock=immediate", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(4) // readers; writes serialise on the file lock anyway
db.SetConnMaxIdleTime(5 * time.Minute)
if err := db.PingContext(context.Background()); err != nil {
return nil, err
}
return db, nil
}
func insertUsers(ctx context.Context, db *sql.DB, users []struct{ Email, Name string }) error {
tx, err := db.BeginTx(ctx, nil) // _txlock=immediate makes this BEGIN IMMEDIATE
if err != nil {
return err
}
defer tx.Rollback() // no-op after Commit
stmt, err := tx.PrepareContext(ctx, `INSERT INTO users (email, name) VALUES (?, ?)
ON CONFLICT (email) DO UPDATE SET name = excluded.name`)
if err != nil {
return err
}
defer stmt.Close()
for _, u := range users {
if _, err := stmt.ExecContext(ctx, u.Email, u.Name); err != nil {
return fmt.Errorf("insert %s: %w", u.Email, err)
}
}
return tx.Commit()
}For a write-heavy service, open two *sql.DB values on the same file: one with SetMaxOpenConns(1) for writes and one for reads. That turns lock contention into an in-process queue with proper context cancellation instead of busy_timeout spinning. :memory: databases are per connection; a pool of size greater than one sees several empty databases, so use file::memory:?cache=shared or SetMaxOpenConns(1) in tests. Migrations are simplest as a slice of SQL strings applied in a transaction, advancing PRAGMA user_version. See Go for context and error conventions.
From Python#
The standard library sqlite3 module wraps the system SQLite. Python 3.12 added the autocommit attribute, which is the recommended way to control transactions; the older isolation_level behaviour of implicitly opening transactions before DML and never before DDL is a persistent source of “why is my schema change not committed” bugs.
import sqlite3
from contextlib import closing
def connect(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path, timeout=5.0, autocommit=False) # timeout = busy_timeout in seconds; 3.12+ autocommit
conn.row_factory = sqlite3.Row # rows indexable by name
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute("PRAGMA foreign_keys = ON")
return conn
with closing(connect("app.db")) as conn:
with conn: # commits on success, rolls back on exception; does not close
conn.executemany(
"INSERT INTO users (email, name) VALUES (:email, :name) ON CONFLICT (email) DO UPDATE SET name = excluded.name",
[{"email": "a@example.com", "name": "A"}, {"email": "b@example.com", "name": "B"}],
)
for row in conn.execute("SELECT id, email FROM users WHERE active = ? ORDER BY id", (1,)):
print(row["id"], row["email"])
# consistent online backup to another file
with closing(sqlite3.connect("app-backup.db")) as dst:
conn.backup(dst, pages=1024) # copies in steps so writers keep going
# ad-hoc queries from the command line with the module's shell (3.12+)
# python -m sqlite3 app.db "SELECT count(*) FROM users"autocommit=False opens a transaction before the first statement and requires commit(); autocommit=True runs each statement alone and you issue BEGIN IMMEDIATE yourself for batches. executemany with a prepared statement is the fast path for bulk inserts; a Python loop calling execute is a few times slower, and either is fine inside one transaction. A connection may be used only from the thread that created it unless check_same_thread=False, and then callers must serialise access themselves. conn.set_trace_callback(print) shows every statement, and sqlite3.sqlite_version tells you which library version the module linked; features such as ->> and STRICT depend on it, not on the Python version. See Python for logging and packaging.
Oneliners#
# Version of the library the shell uses (features depend on this, not the shell version)
sqlite3 :memory: 'SELECT sqlite_version()'
# Row counts for every table
sqlite3 app.db "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" | while read -r t; do printf '%s\t' "$t"; sqlite3 app.db "SELECT count(*) FROM \"$t\""; done
# Size on disk by table and index (needs a build with the dbstat module; most distro builds have it)
sqlite3 app.db "SELECT name, sum(pgsize)/1024 AS kib FROM dbstat GROUP BY name ORDER BY kib DESC LIMIT 15"
# Free pages that VACUUM would reclaim, and the page size
sqlite3 app.db 'PRAGMA freelist_count; PRAGMA page_size;'
# Journal mode, and whether a WAL file is lingering
sqlite3 app.db 'PRAGMA journal_mode;' && ls -l app.db-wal app.db-shm 2>/dev/null
# Checkpoint and truncate the WAL right now
sqlite3 app.db 'PRAGMA wal_checkpoint(TRUNCATE);'
# Who has the file open (long readers pin the WAL)
fuser -v app.db app.db-wal 2>&1 | head
# Query plan without running the statement
sqlite3 app.db 'EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 10;'
# Indexes that exist and their columns
sqlite3 app.db "SELECT tbl_name, name, sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL"
# Compare two databases' schemas
diff <(sqlite3 a.db .schema) <(sqlite3 b.db .schema)
# Diff the data too (sqldiff ships with the sqlite tools package)
sqldiff a.db b.db | head
# Import a CSV into a new table, then look at what came in
sqlite3 app.db '.import --csv users.csv users_raw' 'SELECT * FROM users_raw LIMIT 3'
# Export a query as JSON for jq
sqlite3 -json app.db 'SELECT id, email FROM users' | jq -r '.[] | .email'
# Export one table as CSV with a header
sqlite3 -csv -header app.db 'SELECT * FROM users' > users.csv
# Load a SQL dump into a fresh file, stopping at the first error
sqlite3 -bail new.db < app.sql
# Integrity check with a non-zero exit when it fails
[ "$(sqlite3 app.db 'PRAGMA integrity_check;')" = ok ]
# Recover what can be read from a corrupt file
sqlite3 broken.db .recover | sqlite3 recovered.db
# Set the schema version the application uses for migrations
sqlite3 app.db 'PRAGMA user_version = 7;'
# Foreign key violations in existing data
sqlite3 app.db 'PRAGMA foreign_key_check;'
# Shrink an in-place copy without touching the original
sqlite3 app.db "VACUUM INTO 'app-compact.db'"
# Run a read-only ad-hoc report against a live database
sqlite3 -readonly -box app.db 'SELECT date(created_at) d, count(*) FROM orders GROUP BY d ORDER BY d DESC LIMIT 14'
# Time a query
sqlite3 app.db '.timer on' 'SELECT count(*) FROM orders WHERE created_at >= date("now", "-30 days");'
# Show compile options that matter (FTS5, JSON, RTREE, THREADSAFE)
sqlite3 :memory: 'PRAGMA compile_options;' | grep -E 'FTS5|JSON|RTREE|THREADSAFE|DBSTAT'Scripts#
Backup with verification and rotation: takes an online backup through the backup API, checks it, compresses it and keeps the newest N. Deletes older backups in the target directory.
#!/usr/bin/env bash
# usage: sqlite-backup.sh DB_PATH BACKUP_DIR [KEEP] default KEEP=14
set -euo pipefail
db=${1:?database path}; dir=${2:?backup dir}; keep=${3:-14}
[[ -f $db ]] || { printf 'no such database: %s\n' "$db" >&2; exit 2; }
mkdir -p "$dir"
name=$(basename "$db" .db)
out="$dir/$name-$(date +%Y%m%dT%H%M%S).db"
sqlite3 "$db" ".backup '$out'" # consistent snapshot; writers continue in WAL mode
check=$(sqlite3 "$out" 'PRAGMA integrity_check;')
[[ $check == ok ]] || { printf 'integrity check failed on %s: %s\n' "$out" "$check" >&2; rm -f "$out"; exit 1; }
gzip -9 "$out"
printf 'wrote %s (%s)\n' "$out.gz" "$(du -h "$out.gz" | cut -f1)"
# rotate: keep the newest $keep, delete the rest
ls -1t "$dir/$name-"*.db.gz 2>/dev/null | tail -n +"$((keep + 1))" | while IFS= read -r old; do
rm -f -- "$old"; printf 'removed %s\n' "$old"
doneHealth report for a database: size, WAL state, free pages, missing statistics, tables without a primary key and foreign key violations. Read-only.
#!/usr/bin/env bash
# usage: sqlite-health.sh DB_PATH
set -euo pipefail
db=${1:?database path}
q() { sqlite3 -readonly "$db" "$1"; }
printf 'file %s (%s)\n' "$db" "$(du -h "$db" | cut -f1)"
printf 'version %s\n' "$(q 'SELECT sqlite_version();')"
printf 'journal %s' "$(q 'PRAGMA journal_mode;')"
[[ -f $db-wal ]] && printf ' wal=%s' "$(du -h "$db-wal" | cut -f1)"; echo
printf 'pages %s total, %s free (page_size %s)\n' "$(q 'PRAGMA page_count;')" "$(q 'PRAGMA freelist_count;')" "$(q 'PRAGMA page_size;')"
printf 'quick_check %s\n' "$(q 'PRAGMA quick_check;' | head -1)"
printf 'fk violations %s\n' "$(q 'PRAGMA foreign_key_check;' | wc -l)"
printf 'stats %s\n' "$(q "SELECT CASE WHEN EXISTS (SELECT 1 FROM sqlite_master WHERE name='sqlite_stat1') THEN 'present' ELSE 'missing: run ANALYZE' END;")"
echo 'tables without an explicit primary key:'
q "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql NOT LIKE '%PRIMARY KEY%' AND sql NOT LIKE '%WITHOUT ROWID%'" | sed 's/^/ /'
echo 'largest objects:'
q "SELECT ' ' || name || ' ' || (sum(pgsize)/1024) || ' KiB' FROM dbstat GROUP BY name ORDER BY sum(pgsize) DESC LIMIT 8" 2>/dev/null || echo ' (dbstat module unavailable)'Migration runner in Python: applies numbered .sql files above the current user_version, each in its own transaction, so a failed migration leaves the database at the last good version.
#!/usr/bin/env python3
"""usage: migrate.py DB_PATH MIGRATIONS_DIR (files named 0001_name.sql, 0002_name.sql, ...)"""
import re
import sqlite3
import sys
from pathlib import Path
db_path, mig_dir = sys.argv[1], Path(sys.argv[2])
conn = sqlite3.connect(db_path, timeout=10, autocommit=True)
conn.execute("PRAGMA foreign_keys = ON")
current = conn.execute("PRAGMA user_version").fetchone()[0]
files = sorted(mig_dir.glob("[0-9]*.sql"), key=lambda p: int(re.match(r"\d+", p.name).group()))
for f in files:
version = int(re.match(r"\d+", f.name).group())
if version <= current:
continue
print(f"applying {f.name}")
conn.execute("BEGIN IMMEDIATE")
try:
conn.executescript(f.read_text()) # executescript would COMMIT first in autocommit=False mode; autocommit=True avoids that
conn.execute(f"PRAGMA user_version = {version}")
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
print(f"failed at {f.name}; database remains at version {current}", file=sys.stderr)
raise
current = version
print(f"schema at version {current}")Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
database is locked | Another connection holds the write lock longer than busy_timeout, or busy_timeout is 0 | PRAGMA busy_timeout = 5000 on every connection; shorten transactions; fuser -v app.db to find the holder |
database is locked only when a read turns into a write | DEFERRED transaction cannot upgrade after another commit (SQLITE_BUSY_SNAPSHOT) | BEGIN IMMEDIATE for transactions that will write; _txlock=immediate in Go |
database is locked on a network mount | File locking not implemented by NFS or SMB | Move the database to local disk; SQLite documents this as unsupported |
database disk image is malformed | Copied while open, WAL mismatch, hardware, or a network filesystem | PRAGMA integrity_check; .recover into a new file; restore a .backup |
attempt to write a readonly database on a SELECT | WAL needs to create -wal and -shm in a directory the process cannot write; or SELinux | Make the directory writable, or open with ?immutable=1 / mode=ro; check ausearch -m avc |
unable to open database file | Wrong path, missing directory, or the process’s working directory differs | Absolute path; ls -ld the directory; systemd WorkingDirectory= |
| WAL file grows without bound | An open read transaction stops checkpoints | Find the long reader (fuser, application cursor left open); PRAGMA wal_checkpoint(TRUNCATE) after it closes |
| File never shrinks after deletes | Freed pages return to the freelist, not to the OS | VACUUM, VACUUM INTO, or auto_vacuum = INCREMENTAL |
| Slow query | Full scan or temp B-tree in EXPLAIN QUERY PLAN | Add the index the plan needs; ANALYZE; avoid functions on indexed columns and leading % in LIKE |
| Thousands of inserts take minutes | One transaction per statement, each with an fsync | Wrap in BEGIN IMMEDIATE ... COMMIT; WAL with synchronous = NORMAL |
| Foreign key not enforced | foreign_keys is off by default per connection | PRAGMA foreign_keys = ON after every connect; PRAGMA foreign_key_check for existing violations |
no such module: fts5 or no such function: json_extract | Library built without FTS5, or older than 3.38 for JSON | PRAGMA compile_options; upgrade the library, not the application |
Python DDL not committed, or executescript committed early | Implicit transaction rules under isolation_level | Use autocommit (3.12+) and manage BEGIN/COMMIT yourself |
:memory: database empty in another goroutine or thread | Each connection gets its own in-memory database | file::memory:?cache=shared, or one connection |
| Ids reused after deleting the highest row | INTEGER PRIMARY KEY reuses the max rowid + 1 | AUTOINCREMENT when ids must never repeat |
too many SQL variables | More than 32766 bound parameters (999 before 3.32) | Batch the IN (...) list, or insert into a temp table and join |