Exploring SQLite from the Shell
A hands-on reference for opening, triaging, profiling, and maintaining any SQLite
database file from the sqlite3 shell — from a cold .dbinfo read to
VACUUM, .recover, and everything a working engineer needs in between.
1 · Anatomy of a .db File
A SQLite database is not a server process, a directory, or a proprietary blob — it is one ordinary file, and nearly everything about it is discoverable from that file alone, starting with its first 100 bytes.
Every SQLite database file begins with a 100-byte header. The first 16 bytes of that header are
a fixed, zero-terminated magic string — "SQLite format 3\000", always exactly 16
bytes — which is how a shell, a library, or a tool like file recognizes a file as a
SQLite database at all, independent of its name or extension.
[fileformat2.html]
Two bytes into the header, at offset 16, sits the database's page size: a big-endian integer
that must be a power of two between 512 and 32768, or the special value 1, which
means 65536 (a page size added in SQLite 3.7.1, 2010-08-23). Everything after the header is "one
or more pages" of that single, uniform size, fixed for the life of the file. Pages are numbered
starting at 1, with a theoretical maximum of 4,294,967,294 pages in one database.
[fileformat2.html]
At any moment, every page in the file serves exactly one purpose: a b-tree page (table interior,
table leaf, index interior, or index leaf), a freelist page (trunk or leaf), a payload overflow
page, a pointer-map page, or the single lock-byte page. Each table and each index in the database
is physically implemented as its own b-tree — one b-tree per table, one b-tree
per index — and a b-tree's root page number is exactly the value stored in the schema table's
rootpage column.
[fileformat2.html]
Page 1 is special twice over: it holds the 100-byte header, and it is itself the root
page of a table b-tree — the b-tree for a table named sqlite_schema, which stores
the complete database schema. Every other table's and index's root page is located by looking it
up in sqlite_schema first, so this one page anchors the discovery of every other
b-tree in the file.
[fileformat2.html]
Everything .schema shows you is rows in one table: sqlite_schema.
There's no separate "schema engine" underneath the shell's introspection commands —
.tables, .schema, and .indexes are all just formatted
queries against this one ordinary-looking table that happens to live on page 1.
[schematab.html]
A database file sometimes has company: a -wal, -shm, or
-journal sidecar file sitting right next to it, present only some of the time
depending on journal mode and what the last writer was doing when it exited. What each sidecar
means, and why you should never delete one by hand, is covered in full in
§9 (Safety).
[wal.html]
Sources: The Database File Format · The Schema Table · Write-Ahead Logging
2 · First 60 Seconds with Any .db
Handed an unfamiliar .db file, here is a seven-step ritual that costs well under a
minute and never risks writing to it. Every command below is real, run against the
workshop.db this guide builds in §3's Lab 0.
-
Identify it before you trust the extension.
filereads the magic header, not the filename:$ file workshop.db mystery.db not-a-db.db workshop.db: SQLite 3.x database, last written using SQLite version 3043002, unused bytes 12, file counter 21, database pages 156, cookie 0x12, schema 4, UTF-8, version-valid-for 21 mystery.db: SQLite 3.x database, last written using SQLite version 3043002, unused bytes 12, file counter 1, database pages 156, cookie 0x1, schema 4, UTF-8, version-valid-for 1 not-a-db.db: ASCII text[local sqlite3 3.43.2, captured 2026-07-17]
-
Check size and sidecars.
ls -lthe file, then glob for leftover WAL/journal companions:$ ls -l workshop.db $ ls -l workshop.db-wal workshop.db-shm workshop.db-journal 2>/dev/nullThe file is exactly
638,976bytes —page_size × page_count(4096 × 156) for this database. A clean multiple of the page size is what a healthy, checkpointed database looks like from the outside. The sidecar glob comes back empty here, because a freshly built database that closed cleanly reports plainjournal_mode = deleteby default — sidecars are the exception, not the rule; §9 covers what to do when one is present.[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
-
Open it so you can't break it.
-readonlymakes any write fail instead of silently proceeding against someone's live application file:$ sqlite3 -readonly workshop.db "INSERT INTO tags(name) VALUES('nope')" Error: stepping, attempt to write a readonly database (8)stock frames the rejection as
Error: stepping, attempt to write a readonly database (8)(8 =SQLITE_READONLY); brew reports the identical rejection asError in Nth command line argument: attempt to write a readonly database— no numeric code, different prefix. Either way, nothing was written; the same guarantee holds for thefile:workshop.db?mode=roURI form.[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
-
.dbinfo— the fastest way to "read" a file without a schema dump. Stock and brew agree on 20 of its 21 lines forworkshop.db:Field Value Why it matters database page size 4096 the header field from §1, in bytes write/read format 1 1 = legacy rollback-journal format, 2 = WAL format reserved bytes 12 space reserved per page for extensions (e.g. encryption) database page count 156 × page size = the file's real byte size freelist page count 0 unused pages waiting to be reclaimed — none yet schema cookie 18 bumps on every schema change; a cheap "has the schema changed" check schema format 4 which historical schema-table dialect wrote this file text encoding utf8 not UTF-16le/UTF-16be — matters for byte-level tooling number of tables / indexes / triggers / views 14 / 5 / 1 / 1 your first size-up before running a single SELECTschema size 2565 bytes of schema text stored in sqlite_schema.sqlThe one line excluded above,
data version, is a connection-local counter that legitimately varies run-to-run regardless of binary — not a real version difference. A handful of other.dbinfolines (including any app-defineduser version/application idtags) fall outside this guide's captured ground truth, since this particular build script never sets them; don't expect every line.dbinfoprints to be covered here.[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
-
List what's actually in it.
.tablesqueriessqlite_schemafor you and formats the result into columns:$ sqlite3 -readonly workshop.db .tables comments issue_search_data issue_search_docsize projects events issue_search_idx issue_tags tags issue_search issue_search_config open_issues users issue_search_content issuesstock packs these into 4 columns per row; brew packs the identical 14 names into 5 columns — a rendering-width difference only, not a data difference. Five of these fourteen names (
issue_search_*) are FTS5 shadow tables, not ordinary content — more on that in §5.[local sqlite3 3.43.2, captured 2026-07-17]
-
Get a row count for every table in one loop. No dot-command does this directly, but
sqlite_schemaplus a shell loop does:$ sqlite3 workshop.db "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'" \ | while read -r t; do echo "$t $(sqlite3 workshop.db "SELECT count(*) FROM \"$t\"")"; done users 40 projects 8 issues 500 comments 2000 tags 12 issue_tags 826 events 5000 issue_search 500 issue_search_data 11 issue_search_idx 9 issue_search_content 500 issue_search_docsize 500 issue_search_config 1Byte-identical on both binaries. This is usually the fastest way to spot "the table that's suspiciously empty" or "the table that's 10x bigger than expected" before writing a single real query.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
-
Skim the schema, human-formatted.
.schema --indentreformatsCREATEstatements for readability without changing what they say — it only removes column-alignment whitespace and tightensCHECK (...)toCHECK(...), confirmed byte-identical in content on both binaries:$ sqlite3 -readonly workshop.db ".schema --indent issue_tags" CREATE TABLE issue_tags( issue_id INTEGER NOT NULL REFERENCES issues(id), tag_id INTEGER NOT NULL REFERENCES tags(id), PRIMARY KEY(issue_id, tag_id) ) WITHOUT ROWID;That trailing
) WITHOUT ROWID;is itself a planted quirk — a composite-key table clustered directly on its primary key instead of a separate rowid; more in §5.[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Never open a live application's database read-write "just to look." Even a read that happens
to land inside a transaction can be enough to block a writer, and a stray UPDATE or
DELETE you didn't mean to run is unrecoverable without a backup. Always reach for
-readonly or a file:...?mode=ro URI first — the full treatment of what
"read-only" does and doesn't protect you from is in §9.
The .db extension means nothing — only the 16-byte magic header does. Lab 1
(§3) builds a file literally named not-a-db.db that is plain ASCII
text; file catches it instantly, no extension parsing required.
Sources: Command Line Shell For SQLite · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
3 · Your sqlite3 Binaries
"Just run sqlite3" is not as safe an instruction as it sounds. This machine has
three different sqlite3 binaries installed, and the one bare sqlite3
resolves to on PATH is neither of the two this guide otherwise compares.
| Binary | Path | Version | Notes |
|---|---|---|---|
| Stock (Apple) | /usr/bin/sqlite3 | 3.43.2 (2023-10-10) | ships with macOS |
| MacPorts | /opt/local/bin/sqlite3 | 3.35.5 (2021-04-19) | what bare sqlite3 resolves to here |
| Homebrew | $(brew --prefix sqlite)/bin/sqlite3 | 3.53.3 (2026-06-26) | not on PATH by default |
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Run the discovery ritual yourself before trusting any recipe that just says "run
sqlite3":
$ command -v sqlite3
/opt/local/bin/sqlite3
$ /usr/bin/sqlite3 --version
3.43.2 2023-10-10 13:08:14 1b37c146ee9ebb7acd0160c0ab1fd11017a419fa8a3187386ed8cb32b709aapl (64-bit)
$ $(brew --prefix sqlite)/bin/sqlite3 --version
3.53.3 2026-06-26 20:14:12 d4c0e51e4aeb96955b99185ab9cde75c339e2c29c3f3f12428d364a10d782c62 (64-bit)
$ /opt/local/bin/sqlite3 --version
3.35.5 2021-04-19 18:32:05 1b256d97b553a9611efca188a3d995a2fff712759044ba480f9a0c9e98fae886
command -v sqlite3 resolves to the MacPorts build — the oldest and least-featured
of the three, from 2021. Any recipe that just says "run sqlite3" silently exercises
this one unless you use a full path or have reordered your own PATH.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
PRAGMA compile_options is the next thing worth checking — the two binaries this
guide actively compares were built with meaningfully different feature sets:
$ /usr/bin/sqlite3 :memory: "PRAGMA compile_options" | wc -l
70
$ $(brew --prefix sqlite)/bin/sqlite3 :memory: "PRAGMA compile_options" | wc -l
62
stock reports 70 compile options, brew
62. Stock-only highlights: OMIT_LOAD_EXTENSION (Apple's build disables
load_extension() and the .load dot-command entirely) and
HAS_CODEC_RESTRICTED plus CCCRYPT256 (Apple's restricted-codec/CommonCrypto
hooks, not reachable from ordinary SQL). Brew-only highlights: ENABLE_STAT4,
ENABLE_GEOPOLY, ENABLE_PERCENTILE, ENABLE_OFFSET_SQL_FUNC,
ENABLE_STMTVTAB, ENABLE_UNLOCK_NOTIFY, DIRECT_OVERFLOW_READ.
Neither binary's option list contains an OMIT_FTS*, OMIT_JSON, or
OMIT_RTREE flag — every guide-relevant extension (FTS3/4/5, JSON, R-Tree) is compiled
into both.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
FTS5 availability was worth checking explicitly, and the answer is agreement, not a divergence:
FTS5 is present on both binaries, including the Apple-shipped stock
/usr/bin/sqlite3. A crude probe is a false lead — SELECT fts5('x')
returns a blank line with exit code 0 on both binaries regardless of whether FTS5 is
actually built in, since calling the FTS5 auxiliary function outside a tokenizer context just
yields SQL NULL. Two probes that actually distinguish presence from absence:
$ sqlite3 :memory: "SELECT sqlite_compileoption_used('ENABLE_FTS5')"
1
$ sqlite3 :memory: "CREATE VIRTUAL TABLE t USING fts5(x)"
Both return 1 and succeed silently, respectively, on stock and brew.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Bare sqlite3 on this machine's PATH resolves to the MacPorts build —
neither the stock Apple build nor the newer Homebrew build sit on PATH at all. The
Homebrew one must always be addressed through its real prefix:
$(brew --prefix sqlite)/bin/sqlite3 — the exact construction every brew-side command
in this guide uses, since assuming a bare sqlite3 on PATH reaches the
Homebrew build is not a safe assumption here.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Wherever you see a stock or brew chip next to a command in this guide, it means the Version diff register (built from side-by-side captures on this machine) records a real divergence for that exact command — not a guess, and not every command. No chip means the two binaries agreed in ground truth for that command; don't read anything into its absence beyond "not tested to differ."
A practical setup for working with both: an alias that always reaches the Homebrew build by its
real prefix, leaving bare sqlite3 alone for whatever your PATH already
resolves it to:
alias sqlite3-brew='$(brew --prefix sqlite)/bin/sqlite3'
Homebrew formulas lagging behind their own project's releases is a gotcha this guide has seen
before in a completely different tool — see the
Gas Town guide's §3 for Homebrew Core sitting two
releases behind Gas Town's own tap. The lesson generalizes: check what a formula actually ships
before trusting brew install/upgrade to be current.
One transcript convention from here on: for readability, this guide's transcripts write the
command as plain sqlite3, the same way the upstream docs and most transcripts online
do — they are not literally re-run through whatever bare sqlite3 resolves to on
PATH. On a machine with this same shadowing, substitute
/usr/bin/sqlite3 or the sqlite3-brew alias above for whichever binary a
given transcript is actually demonstrating.
Goal: build the shared example database this whole guide uses, on both binaries, and prove the build is deterministic.
-
Save the script below as
build-workshop.sql— every table, view, trigger, and planted quirk this guide refers to comes from this one file:-- Lab 0: build workshop.db — a small issue tracker with planted quirks. -- Deterministic: running it twice produces identical data. PRAGMA foreign_keys = ON; CREATE TABLE users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT, created_at TEXT NOT NULL ); CREATE TABLE projects ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, owner_id INTEGER NOT NULL REFERENCES users(id), created_at TEXT NOT NULL ); CREATE TABLE issues ( id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES projects(id), author_id INTEGER NOT NULL REFERENCES users(id), assignee_id INTEGER REFERENCES users(id), -- quirk: NULL-heavy title TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('open','in_progress','closed')), priority INTEGER, -- quirk: mixed affinity created_at TEXT NOT NULL, closed_at TEXT ); CREATE INDEX idx_issues_project ON issues(project_id); CREATE TABLE comments ( id INTEGER PRIMARY KEY, issue_id INTEGER NOT NULL REFERENCES issues(id), author_id INTEGER NOT NULL REFERENCES users(id), body TEXT NOT NULL, body_len INTEGER GENERATED ALWAYS AS (length(body)) VIRTUAL, created_at TEXT NOT NULL ); CREATE INDEX idx_comments_issue ON comments(issue_id); CREATE TABLE tags ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE ); CREATE TABLE issue_tags ( issue_id INTEGER NOT NULL REFERENCES issues(id), tag_id INTEGER NOT NULL REFERENCES tags(id), PRIMARY KEY (issue_id, tag_id) ) WITHOUT ROWID; CREATE TABLE events ( -- biggest table; quirk: no index on issue_id id INTEGER PRIMARY KEY, issue_id INTEGER NOT NULL, actor_id INTEGER, kind TEXT NOT NULL, at TEXT NOT NULL ); CREATE VIEW open_issues AS SELECT i.id, p.name AS project, i.title, i.priority, u.username AS author FROM issues i JOIN projects p ON p.id = i.project_id JOIN users u ON u.id = i.author_id WHERE i.status <> 'closed'; CREATE TRIGGER trg_issue_close AFTER UPDATE OF status ON issues WHEN NEW.status = 'closed' AND OLD.status <> 'closed' BEGIN INSERT INTO events (issue_id, actor_id, kind, at) VALUES (NEW.id, NEW.assignee_id, 'closed', NEW.created_at); END; -- Deterministic pseudo-random stream: LCG r(n+1) = (r*1103515245 + 12345) mod 2^31 WITH RECURSIVE rnd(n, r) AS ( SELECT 1, 42 UNION ALL SELECT n + 1, (r * 1103515245 + 12345) % 2147483648 FROM rnd WHERE n < 40 ) INSERT INTO users (id, username, email, created_at) SELECT n, 'user' || printf('%02d', n), CASE WHEN r % 7 = 0 THEN NULL ELSE 'user' || printf('%02d', n) || '@example.test' END, date('2024-01-01', '+' || (r % 300) || ' days') FROM rnd; INSERT INTO projects (id, name, owner_id, created_at) VALUES (1, 'atlas', 3, '2024-01-15'), (2, 'beacon', 7, '2024-02-01'), (3, 'cascade', 1, '2024-02-20'), (4, 'drift', 12, '2024-03-05'), (5, 'ember', 3, '2024-04-11'), (6, 'flint', 22, '2024-05-02'), (7, 'geyser', 9, '2024-05-30'), (8, 'harbor', 30, '2024-06-14'); INSERT INTO tags (id, name) VALUES (1,'bug'),(2,'feature'),(3,'docs'),(4,'perf'),(5,'security'),(6,'ui'), (7,'api'),(8,'ci'),(9,'refactor'),(10,'question'),(11,'blocked'),(12,'good-first'); WITH RECURSIVE rnd(n, r) AS ( SELECT 1, 4242 UNION ALL SELECT n + 1, (r * 1103515245 + 12345) % 2147483648 FROM rnd WHERE n < 500 ) INSERT INTO issues (id, project_id, author_id, assignee_id, title, status, priority, created_at, closed_at) SELECT n, (r % 8) + 1, (r % 40) + 1, CASE WHEN r % 5 < 3 THEN NULL ELSE ((r / 7) % 40) + 1 END, -- ~60% NULL 'Issue ' || n || ': ' || CASE r % 6 WHEN 0 THEN 'crash on startup' WHEN 1 THEN 'add export option' WHEN 2 THEN 'docs unclear' WHEN 3 THEN 'slow query' WHEN 4 THEN 'wrong error message' ELSE 'flaky test' END, CASE r % 10 WHEN 0 THEN 'open' WHEN 1 THEN 'open' WHEN 2 THEN 'in_progress' ELSE 'closed' END, CASE WHEN r % 11 = 0 THEN 'high' -- quirk: TEXT in INTEGER column WHEN r % 4 = 0 THEN NULL ELSE (r % 3) + 1 END, date('2024-01-10', '+' || (r % 500) || ' days'), CASE r % 10 WHEN 0 THEN NULL WHEN 1 THEN NULL WHEN 2 THEN NULL ELSE date('2024-01-10', '+' || (r % 500 + r % 90 + 1) || ' days') END FROM rnd; WITH RECURSIVE rnd(n, r) AS ( SELECT 1, 777 UNION ALL SELECT n + 1, (r * 1103515245 + 12345) % 2147483648 FROM rnd WHERE n < 2000 ) INSERT INTO comments (id, issue_id, author_id, body, created_at) SELECT n, (r % 500) + 1, ((r / 3) % 40) + 1, CASE r % 5 WHEN 0 THEN 'Reproduced on my machine.' WHEN 1 THEN 'I think this is related to the ' || (r % 8) + 1 || ' migration.' WHEN 2 THEN 'Fix pushed, please verify.' WHEN 3 THEN 'Cannot reproduce; need more details on the environment and exact steps taken.' ELSE 'Bumping this — still an issue as of this week.' END, date('2024-02-01', '+' || (r % 480) || ' days') FROM rnd; WITH RECURSIVE rnd(n, r) AS ( SELECT 1, 1234 UNION ALL SELECT n + 1, (r * 1103515245 + 12345) % 2147483648 FROM rnd WHERE n < 1200 ) INSERT OR IGNORE INTO issue_tags (issue_id, tag_id) SELECT (r % 500) + 1, (r % 12) + 1 FROM rnd; WITH RECURSIVE rnd(n, r) AS ( SELECT 1, 31337 UNION ALL SELECT n + 1, (r * 1103515245 + 12345) % 2147483648 FROM rnd WHERE n < 5000 ) INSERT INTO events (id, issue_id, actor_id, kind, at) SELECT n, (r % 500) + 1, CASE WHEN r % 9 = 0 THEN NULL ELSE (r % 40) + 1 END, CASE r % 7 WHEN 0 THEN 'created' WHEN 1 THEN 'status_change' WHEN 2 THEN 'assigned' WHEN 3 THEN 'commented' WHEN 4 THEN 'labeled' WHEN 5 THEN 'referenced' ELSE 'mentioned' END, datetime('2024-01-10 08:00:00', '+' || (r % 40000) || ' minutes') FROM rnd; CREATE VIRTUAL TABLE issue_search USING fts5(title, body); INSERT INTO issue_search (rowid, title, body) SELECT i.id, i.title, coalesce(group_concat(c.body, ' '), '') FROM issues i LEFT JOIN comments c ON c.issue_id = i.id GROUP BY i.id; ANALYZE; -
Run it on the stock binary:
$ rm -f workshop.db && /usr/bin/sqlite3 workshop.db < build-workshop.sql && echo STOCK-OK STOCK-OKNo script edits were needed — including the
CREATE VIRTUAL TABLE issue_search USING fts5(title, body)line, which ran clean on stock with no fallback required. -
Run the identical, unedited script on the Homebrew binary:
$ rm -f workshop-brew.db && $(brew --prefix sqlite)/bin/sqlite3 workshop-brew.db < build-workshop.sql && echo BREW-OK BREW-OK
You should now see: two independent builds from the identical script, run on the same binary, are deterministic:
$ h1=$(/usr/bin/sqlite3 workshop.db .dump | shasum)
$ rm -f workshop2.db && /usr/bin/sqlite3 workshop2.db < build-workshop.sql
$ h2=$(/usr/bin/sqlite3 workshop2.db .dump | shasum)
$ [ "$h1" = "$h2" ] && echo DETERMINISTIC
DETERMINISTIC
Both hashes were c4f100075aefb7ac1e0fd6ea525162e57f6ec355. Expected table
counts to confirm your build matches: users 40, projects 8,
issues 500, comments 2000, tags 12,
issue_tags 826, events 5000.
Cleanup: remove the throwaway workshop2.db used only for the determinism check,
plus workshop-brew.db — nothing later in this guide reopens the Homebrew-built copy.
Keep workshop.db — it's the shared example database the rest of
this guide builds on.
$ rm -f workshop2.db workshop-brew.db
stock brew — determinism only
holds within one binary. workshop.db (stock) and
workshop-brew.db (brew), from the identical script, dump to different
hashes (c4f10007… vs. 7fa89a63…) — not a bug. Brew's dump adds a
leading /* WARNING: Script requires that SQLITE_DBCONFIG_DEFENSIVE be disabled */
comment stock never emits; the FTS5 shadow tables' binary blobs differ byte-for-byte (an
internal index-encoding difference, not a data difference); and only brew's dump contains a
sqlite_stat4 table, because only brew was compiled with
ENABLE_STAT4. .dump equality is a same-binary/same-build guarantee
only.
Stretch: re-run the planted-quirk queries against your own build and confirm
they match: SELECT typeof(priority), count(*) FROM issues GROUP BY 1 ORDER BY 2 DESC
should give integer|341, null|113, text|46;
SELECT count(*) FROM events should give 5000; and
.schema issue_tags should end in ) WITHOUT ROWID;.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Goal: given two unlabeled files, decide which is a real SQLite database using nothing but the shell — no application, no prior knowledge of what created them.
-
Create the two files this lab triages: a faithful copy of
workshop.dbmade with the shell's own binary-copy command, and an impostor that only looks like a database from its name:$ sqlite3 workshop.db ".backup mystery.db" $ printf 'this is plain text, not a database\n' > not-a-db.db.backupis documented as writing a binary copy of the database file (an alias for.save) — a different mechanism from.dump's textual SQL output. [cli.html] -
Run
fileon all three — the same command from §2's first step:$ file workshop.db mystery.db not-a-db.db workshop.db: SQLite 3.x database, last written using SQLite version 3043002, unused bytes 12, file counter 21, database pages 156, cookie 0x12, schema 4, UTF-8, version-valid-for 21 mystery.db: SQLite 3.x database, last written using SQLite version 3043002, unused bytes 12, file counter 1, database pages 156, cookie 0x1, schema 4, UTF-8, version-valid-for 1 not-a-db.db: ASCII textThe first divergence is immediate, before
sqlite3ever opens anything: two real SQLite databases, and one plain-text impostor wearing a.dbname. -
Confirm the two real files are structurally the same database, not just the same magic bytes.
file's owndatabase pages 156already matchesworkshop.db's §2.dbinfocapture exactly; only the header's housekeeping counters (file change counter, schema cookie, version-valid-for) differ, because.backupwrites those fresh for the new file it creates.
You should now see: mystery.db is a faithful, intact copy of
workshop.db — same page count, same everything that matters, different header
bookkeeping; not-a-db.db is not a database at all, caught in step 2 before you
ever tried to open it with sqlite3.
Cleanup: rm -f mystery.db not-a-db.db — nothing later in this guide depends on
either file.
Stretch: run PRAGMA integrity_check against mystery.db
yourself before deleting it. This guide didn't need to — a .backup copy of a healthy
database is intact by construction — but confirming it firsthand is the same habit later
maintenance sections build on for databases that aren't known-healthy.
[local sqlite3 3.43.2, captured 2026-07-17]
Sources: Command Line Shell For SQLite · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17 · Gas Town guide §3 (Homebrew version-lag note)
4 · Shell Fluency
The sqlite3 shell speaks two grammars at one prompt, can be driven three different
ways from outside a terminal, and renders the same query result in at least seven visibly
different shapes. None of that is optional trivia — picking the wrong one is the difference
between a script that works and one that silently breaks the moment someone else's
~/.sqliterc is involved.
Two grammars, one prompt. A dot-command configures the shell itself — output
mode, session state, meta-operations like .read or .backup — and must
start at the left margin, live on one line, and never appear mid-statement. Everything else is
ordinary SQL, terminated with ;, free to span multiple lines. The
interactive prompt happily mixes both, line by line; a single non-interactive CLI argument cannot
(see the gotcha below).
[cli.html]
Three ways to run it
Interactive — sqlite3 workshop.db with no further arguments drops
you at the sqlite> prompt. One-shot — extra command-line
arguments after the filename are each treated as one dot-command or SQL statement, evaluated in
order, stdin never read; the docs' own example combines two positional arguments this way:
sqlite3 test.db ".mode box" "SELECT * FROM users;". Piped — with no
extra positional arguments, the shell reads and executes SQL from stdin until it's exhausted,
then exits, non-interactively:
$ echo 'SELECT 1' | sqlite3 workshop.db
1
[cli.html; local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Gotcha, confirmed on both binaries: a dot-command and a SQL statement crammed into one single positional argument (with an embedded newline) does not work — this is a different, narrower case than the docs' two-separate-arguments example above:
$ sqlite3 workshop.db ".mode list
SELECT 1,2;"
stock fails with extra argument: "1,2;";
brew fails with argv[2]: .mode list ... bad argument /
Use ".help .mode" for more info. A bare dot-command alone as the sole positional
argument works fine on both (sqlite3 workshop.db ".tables"). The reliable way to
combine a dot-command with a one-shot SQL statement — the form the rest of this guide uses
throughout — is -cmd:
$ sqlite3 -cmd ".mode box" workshop.db "SELECT id, title, status FROM issues LIMIT 1;"
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
The output-mode gallery
The identical 3-row query, rendered through -cmd ".mode X" -cmd ".headers on" in
seven modes — every fragment below is the real, captured output (not reformatted); where the
research notes only recorded a trimmed excerpt rather than the full 3-row transcript, that's
exactly what's shown here, no filled-in gaps:
$ sqlite3 -cmd ".mode X" -cmd ".headers on" workshop.db "SELECT id, title, status FROM issues LIMIT 3;"
| Mode | Captured output | Reach for this when… |
|---|---|---|
column |
stock left-aligns the numeric id column
(1 Issue 1...); brew right-aligns it
( 1 Issue 1...) with a narrower header gutter. |
reading at a terminal — auto-sizes columns and auto-enables .headers if you
hadn't (since 3.33.0). |
box |
stock: single-line Unicode borders
(┌────┬──...┬─────────────┐ / ├────┼──...┼─────────────┤);
brew: rounded corners, double-line header rule
(╭────┬──...╮ / ╞════╪══...╡). Same underlying data either way. |
the nicest-looking interactive read, on a terminal that renders box-drawing characters
well — the closely-related qbox mode is brew's own interactive default since
3.52.0. |
json |
byte-identical on both: [{"id":1,"title":"Issue 1: crash on startup","status":"in_progress"}, ...] |
piping into jq, a script, or any JSON-consuming tool. |
csv |
byte-identical on both: id,title,status header, then
1,"Issue 1: crash on startup",in_progress, etc. (RFC 4180 quoting on the
comma-bearing title). |
loading into a spreadsheet — or skip the file step with .once -x, the
.excel alias. |
markdown |
identical pipe-table text on both apart from the same numeric-alignment difference as
column/table — the notes don't record a full byte transcript for
this one, only that comparison, so no fabricated table is shown here. |
pasting a result straight into a GitHub issue, PR description, or any Markdown-rendering doc. |
insert |
the default synthetic table name differs when none is given: stock
emits INSERT INTO "table"(id,title,status) VALUES(...) (literally quotes the word
table); brew emits
INSERT INTO tab(id,title,status) VALUES(...) (unquoted, shortened to
tab). |
regenerating INSERT statements to move rows into another table or
database. |
line |
the field/value separator itself differs: stock uses
" = " ( id = 1); brew uses
": " ( id: 1). Only the id field was captured this
way — the same separator carries through title/status and the
remaining rows. |
one wide record at a time — reading a row with many or long columns without horizontal wrapping. |
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Shaping the output further
.headers on/off toggles the header row — verified to behave identically
on both binaries. .width N N... fixes column widths in column mode; a
real capture with .width 4 10 truncates/wraps a status value across two
lines identically on both (in_progres / s for in_progress).
.separator changes the field separator for list-family modes — a real
.separator ';' capture in list mode came back byte-identical on both:
id;status / 1;in_progress / 2;closed.
.nullvalue TEXT sets what a SQL NULL prints as (default: empty string) —
documented as a dot-command and mirrored by the -nullvalue startup flag; no local
capture was made of this one specifically, so nothing beyond the documented default is claimed
here.
[cli.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
Flags worth knowing
-cmd COMMAND runs a dot-command or SQL statement before stdin is read — usable
multiple times, and distinct from the positional one-shot form. Every output mode also has a
direct flag (-json, -line, -csv, -box, …one
per mode, mutually exclusive); a real capture confirmed -json produces the same
shape as .mode json and -line matches .mode line, including
that same " = " vs ": " separator difference. -readonly
opens the database read-only, already used throughout §2.
[cli.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
Redirecting output: .output, .once, .read
.output FILE redirects all subsequent query output to a file (no argument reverts to
stdout); if FILE's first character is |, the rest runs as a shell
command instead, and special forms -e/-x open a temp file in the
system editor / spreadsheet app (.excel is literally an alias for
.once -x). .once behaves the same way but reverts automatically after
the next statement only. Both were verified for real (via -cmd, not the
positional form, which hits the gotcha above): each wrote the expected count(*)
result into a file and left stdout empty. .read FILE executes a .sql
file's statements — verified printing 8 for
SELECT count(*) FROM projects.
[cli.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
~/.sqliterc — powerful, and not on the official docs page
If ~/.sqliterc exists, the shell reads and executes it — as dot-commands or SQL —
before any other input, on every invocation. This isn't documented on the live
cli.html page itself (a full-text search of it turns up no
"sqliterc"/"resource file" match at all); it's corroborated instead by the SQLite project's own
forum, where a maintainer's answer to "how do I ignore my .sqliterc" is
-init /dev/null. That same forum answer's own worked example of what people put
there is .headers on and .mode column — exactly the kind of decorative,
interactive-comfort setting this section is otherwise about.
[SQLite forum]
~/.sqliterc runs before any other input — that includes one-shot and
piped invocations, not just interactive sessions. A .mode column or
.headers on left in there for interactive comfort can silently reshape the output of
a script that assumed the plain list default, with no warning. Guard scripts either
with -init /dev/null (still prints "Loading resources from /dev/null," but your
.sqliterc preferences don't apply) or an explicit -cmd that sets the
mode you actually want.
.shell / .system, and --safe
.shell CMD and .system CMD (documented aliases of each other) run
CMD in a real subshell and print its output — verified identically on both binaries
(hello-from-shell, hello-from-system). At the opposite extreme,
--safe (also -safe) disables everything that could touch anything other
than the one database file named on the command line: .open (unless
:memory: or --hexdb), ATTACH, side-effecting SQL functions
(load_extension(), readfile(), writefile(), …),
.shell/.system, .import, .load,
.backup/.save, and .excel/.once/
.output. A one-time --nonce NONCE + .nonce NONCE pair can
selectively re-enable exactly one restricted statement — the docs call .nonce
dangerous if misused. A real probe confirms ordinary queries still run fine under
--safe, while a blocked command fails on both binaries with the same core message,
framed differently:
$ sqlite3 --safe workshop.db ".shell echo x"
stock: line 0: cannot run .shell in safe mode;
brew: argv[3]: cannot run .shell in safe mode — the same
"framing" difference (line N: vs argv[N]:) that recurs anywhere a one-shot
invocation hits a runtime error.
[cli.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
.mode is the one setting that decides which of the seven+
renderings above the output takes.
[cli.html]"I want X" → mode or flag
| I want… | Reach for |
|---|---|
| A readable table at my terminal | .mode column or .mode box (interactive default since 3.52.0: qbox) |
To feed a script or jq | .mode json / -json |
| To load into a spreadsheet | .mode csv + .once FILE, or .once -x (.excel) |
| To paste into a Markdown doc/issue | .mode markdown |
| To regenerate rows as SQL | .mode insert |
| One wide record per screen | .mode line / -line |
| To make sure a script can't write | -readonly, or a file:...?mode=ro URI |
| To run an untrusted script safely | --safe (escape one call with --nonce/.nonce) |
| To combine a dot-command with one-shot SQL | -cmd ".mode X" db "SQL;" — not one crammed positional argument |
Sources: Command Line Shell For SQLite · Query Result Formatting In The CLI · SQLite Release 3.33.0 changelog · SQLite forum: ignoring .sqliterc · SQLite forum: .sqliterc location · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
5 · Schema Spelunking
Every dot-command that shows you schema — .tables, .schema,
.indexes — is a formatted query against one ordinary table,
sqlite_schema (§1). This section queries it directly, compares the
three ways SQLite exposes introspection (dot-commands, PRAGMA statements, and
pragma_* functions), and ends with a blind schema-mapping lab against an unlabeled
copy of workshop.db.
sqlite_schema: the table behind every dot-command
Every database has exactly one schema table, with no row describing itself:
CREATE TABLE sqlite_schema(type text, name text, tbl_name text, rootpage integer, sql
text);. sqlite_schema is the current name; sqlite_master is kept
as a historical alternate that works everywhere (authorizer callbacks still refer to it by that
name); sqlite_temp_schema/sqlite_temp_master are alternates that only
resolve against a connection's TEMP database.
[schematab.html]
Column type is one of 'table', 'index', 'view',
'trigger' — note 'table' covers virtual tables too; there is no separate
'virtual table' value. name is the object's name, except
auto-generated indexes backing a UNIQUE/PRIMARY KEY constraint, which get synthetic names
sqlite_autoindex_TABLE_N. tbl_name is the owning table (itself, for a
table/view; the indexed table, for an index; the firing table, for a trigger).
rootpage is 0/NULL for views, triggers, and virtual tables — they have no b-tree of
their own. sql holds the normalized CREATE statement, NULL for the
auto-generated constraint indexes.
[schematab.html]
A real, captured example of that autoindex-naming rule: workshop.db's only
UNIQUE-constrained columns without a covering PK — tags.name,
users.username, projects.name — each produce exactly one autoindex,
named sqlite_autoindex_tags_1, sqlite_autoindex_users_1,
sqlite_autoindex_projects_1. issue_tags's composite PRIMARY KEY produces
what pragma_index_list('issue_tags') reports as a fourth,
sqlite_autoindex_issue_tags_1 — but because issue_tags is a
WITHOUT ROWID table, that PK index has no b-tree of its own (the PK is the
table's own clustered storage) and so has no sqlite_schema row at all. It's visible
only through pragma_index_list, never through sqlite_schema or
.indexes — only the first three autoindexes show up there, as the surprise below
confirms.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
.tables and .schema are themselves just formatted queries against this
table — .tables is roughly SELECT name FROM sqlite_schema WHERE type IN
('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY 1 (already captured in
§2's 14-name listing); .schema ?PATTERN? is roughly
SELECT sql FROM sqlite_schema ORDER BY tbl_name, type DESC, name, optionally
restricted to objects matching a glob pattern. Both, like .databases, cover every
attached database unless explicitly restricted.
[cli.html]
Querying sql directly, rather than going through .schema, is how you get
a view or trigger's exact defining text. A real capture:
SELECT sql FROM sqlite_schema WHERE type='view' and WHERE type='trigger'
return byte-identical text on both binaries — exactly the CREATE VIEW/
CREATE TRIGGER statements from §3's Lab 0 script:
CREATE VIEW open_issues AS
SELECT i.id, p.name AS project, i.title, i.priority, u.username AS author
FROM issues i
JOIN projects p ON p.id = i.project_id
JOIN users u ON u.id = i.author_id
WHERE i.status <> 'closed';
CREATE TRIGGER trg_issue_close
AFTER UPDATE OF status ON issues
WHEN NEW.status = 'closed' AND OLD.status <> 'closed'
BEGIN
INSERT INTO events (issue_id, actor_id, kind, at)
VALUES (NEW.id, NEW.assignee_id, 'closed', NEW.created_at);
END;
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
.schema variants vs. querying sql directly
--indent reformats CREATE text for humans without changing what it
says. §2 already captured this byte-for-byte for issue_tags
(.schema --indent issue_tags, ending ) WITHOUT ROWID; with no space
before either paren — the real transform only strips column-alignment whitespace and tightens
CHECK (...) to CHECK(...)). Separately, .schema issues and
.schema --indent issues were captured as byte-identical to each other on
both binaries, planted quirk comments (-- quirk: NULL-heavy,
-- quirk: mixed affinity) preserved verbatim in the stored text — comments inside a
CREATE TABLE's parens really do live on in sqlite_schema.sql, not just in
your source file.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
The same preservation is why .schema comments still shows the generated column
inline, verbatim, exactly as §3's Lab 0 script wrote it — the flagship setup for
the table_info/table_xinfo comparison below:
CREATE TABLE comments (
id INTEGER PRIMARY KEY,
issue_id INTEGER NOT NULL REFERENCES issues(id),
author_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
body_len INTEGER GENERATED ALWAYS AS (length(body)) VIRTUAL,
created_at TEXT NOT NULL
);
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
.fullschema shows the whole schema plus a trailing
sqlite_stat1 dump, bracketed by literal ANALYZE sqlite_schema; lines —
82 lines total, identical count on both binaries, including two representative captured rows:
INSERT INTO sqlite_stat1 VALUES('issues','idx_issues_project','500 63'); and
INSERT INTO sqlite_stat1 VALUES('issue_tags','issue_tags','826 2 1');. A full
diff of the 82 lines between binaries found exactly one difference — the FTS5
shadow-table CREATE TABLE statements themselves:
stock emits CREATE TABLE IF NOT EXISTS
'issue_search_data'(id INTEGER PRIMARY KEY, block BLOB);;
brew emits the same line without
IF NOT EXISTS. Every other CREATE statement and all 11
sqlite_stat1 rows matched exactly.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
.indexes with no argument holds this section's biggest surprise:
stock lists all 5 real indexes, including the three
sqlite_autoindex_* entries; brew's bare
.indexes shows only the 2 named indexes, silently omitting all three autoindexes
(both binaries still know about them — .indexes % and a direct
sqlite_schema query show all 5 on both; this is a .indexes-specific
default-filtering change, not a schema difference). .indexes issues — the
single-table form — returns the identical single row (idx_issues_project) on both.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
PRAGMA table_info vs. table_xinfo
PRAGMA table_info(table) returns one row per normal column:
cid (rank in this result set, not a stable id), name, type,
notnull, dflt_value, pk (0, or 1-based position within the
primary key). It does not report generated or hidden columns. A real capture on
issues returned the documented 9-row cid|name|type|notnull|dflt_value|pk
shape identically on both binaries, id correctly flagged pk=1, every
other column pk=0. PRAGMA table_xinfo(table) adds a hidden
column (0 normal, 2/3 a generated column, 1 a
virtual-table hidden column) and is the complete superset — its extra rows are exactly what
table_info omits.
[pragma.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
The concrete, real demonstration is comments' generated body_len column
from above:
| Query | Columns returned |
|---|---|
SELECT name FROM pragma_table_info('comments') |
id, issue_id, author_id, body, created_at — 5 rows, body_len omitted |
SELECT name, hidden FROM pragma_table_xinfo('comments') |
all 6 columns; body_len carries hidden=2 |
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
If a column vanishes between table_info and table_xinfo, it's
generated or hidden — not deleted, not a bug. table_xinfo is always the complete
picture; table_info is the "columns you can write to directly" view.
[pragma.html]
The pragma_* table-valued form, and the flagship join
Pragmas with no side effects can also be called as table-valued functions
(pragma_table_info('t') instead of PRAGMA table_info(t)) — added in
SQLite 3.16.0 (2017-01-02). The payoff is that a function-form pragma can be filtered with
WHERE, aggregated, and — the one thing a plain PRAGMA statement can
never do — joined against another table or pragma, one row per match.
[pragma.html]
That join is this section's flagship recipe: every column of every real table, one row per column, in a single query —
SELECT m.name, p.name, p.type, p."notnull", p.pk
FROM sqlite_schema m
JOIN pragma_table_info(m.name) p
WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%';
A real run of exactly this join returned 45 rows on both binaries, and
diff-ing the two outputs reported zero differences. The notes don't carry the full
45-row transcript, but they do independently confirm this excerpt of it — the same 5
comments rows already captured above (join output reformatted as
table.column, no new data):
comments|id
comments|issue_id
comments|author_id
comments|body
comments|created_at
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Foreign keys
PRAGMA foreign_key_list(table) returns one row per REFERENCES
constraint declared in that table's CREATE TABLE. A real capture on
issues returned all 3 of its declared foreign keys, every one
NO ACTION/NO ACTION/NONE for
on-update/on-delete/match:
| From | References | on_update / on_delete / match |
|---|---|---|
assignee_id | users.id | NO ACTION / NO ACTION / NONE |
author_id | users.id | NO ACTION / NO ACTION / NONE |
project_id | projects.id | NO ACTION / NO ACTION / NONE |
[pragma.html; local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
The rest of the FK map reads straight off §3's Lab 0 script (already
byte-verified against the built database) rather than a fresh pragma capture per table:
projects.owner_id → users.id, comments.issue_id → issues.id,
comments.author_id → users.id, issue_tags.issue_id → issues.id,
issue_tags.tag_id → tags.id. One column looks like it should be on this list and
isn't: events.issue_id is declared plain INTEGER NOT NULL, with no
REFERENCES clause at all — PRAGMA foreign_key_list(events) returns zero
rows. The relationship is real (every event was generated against a valid issue id), just not
enforced by the schema; Lab 2 below asks you to notice exactly this.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Indexes: index_list / index_info
PRAGMA index_list(table) lists a table's indexes (name, uniqueness, origin —
c = CREATE INDEX, u = UNIQUE constraint, pk =
PRIMARY KEY constraint); PRAGMA index_info(index) lists that index's key columns.
Three real captures:
| Query | Real result |
|---|---|
PRAGMA index_list(issues) |
1 row: idx_issues_project, non-unique, origin c |
PRAGMA index_info(idx_issues_project) |
1 row: column project_id, seqno=0 |
SELECT * FROM pragma_index_list('issue_tags') |
1 row: sqlite_autoindex_issue_tags_1, unique=1,
origin=pk, partial=0 — the WITHOUT ROWID table's composite PK,
surfaced as an index |
[pragma.html; local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
WITHOUT ROWID detection
§2 already captured .schema --indent issue_tags ending
) WITHOUT ROWID; — the most direct tell. The pragma_index_list('issue_tags')
capture just above is a second, independent signal: a WITHOUT ROWID table's declared PRIMARY KEY
shows up as an origin=pk autoindex the same way an ordinary rowid table's would.
Separately (documented, not locally re-verified): since SQLite 3.30.0, running
PRAGMA index_info/index_xinfo with a WITHOUT ROWID table's own name —
when no index by that name exists — returns that table's physical key layout directly.
[pragma.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
STRICT tables
CREATE TABLE ... STRICT (added in SQLite 3.37.0, 2021-11-27) swaps manifest typing
for rigid, container-based typing. A real probe — CREATE TABLE strict_probe(x INTEGER)
STRICT on a throwaway database — succeeds silently on both binaries; workshop.db
itself has no STRICT tables (the planted mixed-affinity quirk on issues.priority
specifically needs manifest typing to demonstrate). Inserting a TEXT value into that STRICT
INTEGER column is rejected on both, framing differs the same way it does everywhere else in this
guide:
$ sqlite3 strict-test.db "INSERT INTO strict_probe(x) VALUES ('not-a-number')"
stock: Error: stepping, cannot store TEXT value in INTEGER
column strict_probe.x (19) (19 = SQLITE_CONSTRAINT); brew:
Error in 2nd command line argument: cannot store TEXT value in INTEGER column
strict_probe.x (no numeric code). The core message is identical; only the wrapper differs.
[datatype3.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
FTS5, virtual tables, and shadow-table noise
CREATE VIRTUAL TABLE ... USING fts5(...) still gets type='table' in
sqlite_schema — there's no separate virtual-table type value. A real query pins this
down exactly:
$ sqlite3 -readonly workshop.db "SELECT sql FROM sqlite_schema WHERE name='issue_search'"
CREATE VIRTUAL TABLE issue_search USING fts5(title, body)
Creating that one virtual table also creates 5 real backing "shadow" tables —
issue_search_data, issue_search_idx, issue_search_content,
issue_search_docsize, issue_search_config — confirmed both by
§2's .tables capture (all 6 names mixed in alphabetically with the
8 ordinary tables, no visual distinction) and by
SELECT type, name, tbl_name FROM sqlite_schema WHERE name LIKE 'issue_search%', whose
6 rows all carry type='table' and name = tbl_name. The naming suffix is
the only tell — schema type alone never distinguishes FTS5 machinery from an ordinary table.
[fts5.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
Dot-command vs. PRAGMA vs. pragma_*
| Need | Dot-command | PRAGMA statement | pragma_* function |
|---|---|---|---|
| Column list | — | PRAGMA table_info(t) | pragma_table_info('t') |
| Foreign keys | — | PRAGMA foreign_key_list(t) | pragma_foreign_key_list('t') |
| Indexes | .indexes ?t? | PRAGMA index_list(t) | pragma_index_list('t') |
| Full schema text | .schema ?PATTERN?, .fullschema | — | SELECT sql FROM sqlite_schema |
| Table/view names | .tables ?PATTERN? | — | SELECT name FROM sqlite_schema |
| Interactive-only, fastest to type | yes — that's the whole point | no | no |
| Usable from a script/host-language driver | no (dot-commands are shell-only) | yes, as a statement | yes, as a query |
| Filterable / joinable with other data | no | no — one fixed result shape | yes — it's an ordinary table-valued FROM source |
Goal: using nothing but the shell against an unlabeled copy of workshop.db, map
its schema from scratch — table list with row counts, PK/FK relationships, the append-heaviest
table, and one instance each of a view, a trigger, a generated column, a WITHOUT ROWID table, and
a virtual table — before checking your answers.
-
If you followed Lab 1's cleanup,
mystery.dbno longer exists — recreate it the same way Lab 1 did, a faithful binary copy ofworkshop.db:$ sqlite3 workshop.db ".backup mystery.db"Because
.backupis a binary copy (not a re-run of the build script),mystery.dbcarries the exact same tables, rows, and row counts asworkshop.db— every number below applies to it unchanged. -
List every table and get a row count for each, the same
sqlite_schema+ shell-loop recipe from §2, pointed atmystery.db:$ sqlite3 mystery.db "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'" \ | while read -r t; do echo "$t $(sqlite3 mystery.db "SELECT count(*) FROM \"$t\"")"; done users 40 projects 8 issues 500 comments 2000 tags 12 issue_tags 826 events 5000 issue_search 500 issue_search_data 11 issue_search_idx 9 issue_search_content 500 issue_search_docsize 500 issue_search_config 17 "real" tables plus a 5-table FTS5 shadow cluster (the
issue_search_*names) — 13 rows of table+count in total, plus theissue_searchvirtual table itself makes 8 top-level objects. -
Map primary and foreign keys with
PRAGMA foreign_key_list, one table at a time (run it againstmystery.dbforissues,projects,comments,issue_tags, andeventsin turn):$ sqlite3 mystery.db "PRAGMA foreign_key_list(issues)"For
issues, this returns the same 3 rows already captured in the "Foreign keys" table above (assignee_id/author_id→users.id,project_id→projects.id).eventsis the interesting negative result: itsforeign_key_listcomes back empty, even thoughissue_idclearly relates toissues— a relationship that exists in practice but was never declared withREFERENCES. -
Find the append-heaviest table — no dot-command does this directly, but step 2's counts already answer it:
eventsat 5000 rows is 2.5× the next-largest real table (comments, 2000). -
Find the view, the trigger, the generated column, the WITHOUT ROWID table, and the virtual table — five one-line queries:
$ sqlite3 mystery.db "SELECT name FROM sqlite_schema WHERE type='view'" open_issues $ sqlite3 mystery.db "SELECT name FROM sqlite_schema WHERE type='trigger'" trg_issue_close $ sqlite3 mystery.db "SELECT name, hidden FROM pragma_table_xinfo('comments') WHERE hidden!=0" body_len|2 $ sqlite3 mystery.db ".schema --indent issue_tags" | tail -1 ) WITHOUT ROWID; $ sqlite3 mystery.db "SELECT sql FROM sqlite_schema WHERE name='issue_search'" CREATE VIRTUAL TABLE issue_search USING fts5(title, body)The
WITHOUT ROWIDline and the FTS5CREATE VIRTUAL TABLEline are exact ground-truth captures reused from §2 and this section's FTS5 discussion above; the view/trigger names andbody_len|2are single deterministic values read straight off the byte-verified schema in §3's Lab 0 script (there is exactly one view, one trigger, and one hidden column to find) rather than a separately re-captured transcript.
You should now see: 7 real tables (users, projects,
issues, comments, tags, issue_tags,
events) plus one FTS5 virtual table with 5 shadow tables; a PK/FK graph rooted at
users and projects feeding into issues; events
as both the append-heaviest table and the one relationship that isn't a declared FK;
and one each of a view, trigger, generated column, and WITHOUT ROWID table. Check your map
against the answer key below before opening it.
Cleanup: rm -f mystery.db once you've compared your map — nothing later in this
guide depends on it.
Stretch: before deleting it, run the flagship join from earlier
(SELECT m.name, p.name, ... FROM sqlite_schema m JOIN pragma_table_info(m.name) p WHERE
m.type='table' AND m.name NOT LIKE 'sqlite_%') against mystery.db and confirm
you also get 45 rows.
[local sqlite3 3.43.2, captured 2026-07-17]
Answer key
| Find | Answer |
|---|---|
| Tables | 7 real (users 40, projects 8, issues 500, comments 2000, tags 12, issue_tags 826, events 5000) + 1 virtual (issue_search 500, plus 5 shadow tables) |
| Append-heaviest | events — 5000 rows, the build script's own comment literally calls it "biggest table" |
| View | open_issues — non-closed issues joined to project + author |
| Trigger | trg_issue_close — fires AFTER UPDATE OF status, inserts a closed event |
| Generated column | comments.body_len — GENERATED ALWAYS AS (length(body)) VIRTUAL, hidden=2, invisible to table_info |
| WITHOUT ROWID | issue_tags — composite PK (issue_id, tag_id), clustered directly on it |
| Virtual table | issue_search — FTS5 on (title, body), 5 shadow tables (_data/_idx/_content/_docsize/_config) |
| Undeclared relationship | events.issue_id — no REFERENCES clause; foreign_key_list(events) is empty despite the real relationship |
Sources: The Schema Table · PRAGMA Statements · Command Line Shell For SQLite · SQLite FTS5 Extension · Datatypes In SQLite · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
6 · Contents Profiling
Knowing the schema (§5) tells you the shape of the containers. It says nothing
about what's actually inside them. This section is a small toolkit of shell recipes for looking
at real values — how much of a column is NULL, how many distinct values it holds,
what range it spans — and a reminder that SQLite will cheerfully let a column hold data its own
declared type never promised. Lab 3 turns the toolkit loose on workshop.db to find
two things planted in it that a schema read alone would never reveal.
Sampling without loading everything
Two LIMIT variants cover most first looks at a table: SELECT * FROM t LIMIT
5 for the lowest-rowid rows (a "head"), SELECT * FROM t ORDER BY id DESC LIMIT
5 for the highest (a "tail"). Both were run against issues and returned
identical rows on both binaries — the same kind of rowid-ordered sample already captured in
§4's output-mode gallery (SELECT id, title, status FROM issues LIMIT
3, first row 1|Issue 1: crash on startup|in_progress) is exactly this
pattern in practice, so it isn't reproduced a second time here.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Why "random" isn't free
ORDER BY random() looks like a cheap way to eyeball a sample, but there's no index
SQLite could ever build for it — random() returns a different value on every
invocation, so the only way to honor the ORDER BY is to evaluate it for every row and
sort the results, i.e. a full table scan, every time, no matter how the table is indexed
elsewhere. .timer on makes the cost visible, with one binary-specific catch:
$ sqlite3 -cmd ".timer on" workshop.db "SELECT id, title FROM events ORDER BY random() LIMIT 5;"
stock: the query runs and returns rows, but .timer on
is a silent no-op — no error, and no Run Time: line ever prints, on this or any other
query. brew, identical invocation, prints a real timing line after the
same query against the 5000-row events table:
Run Time: real 0.001437 user 0.001059 sys 0.000374
Every "compare cold vs. indexed query cost with .timer on" recipe in this guide —
including Lab 4 below — only actually shows a number on the Homebrew binary; stock will run the
same query just as correctly, just silently. The row values themselves aren't reproduced here
(the notes preserved the timing, not a five-row transcript of events) — the point of
this capture is the cost, not the content.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Counting everything: the flagship loop, applied to profiling
§2's step 6 already established the recipe for a per-table row count in one pass
— sqlite_schema filtered to real tables, piped through a while read loop
calling count(*) per name. That same loop is this section's starting point for
profiling too: before asking anything about a column's contents, know how many rows you're
profiling. Re-run against workshop.db itself (not mystery.db) and you
get the identical 13-table listing already shown there — events 5000 still the
largest by a wide margin.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Profiling one column: NULLs, distinctness, range
Three cheap aggregate recipes cover most of what's worth knowing about a single column, none of
them requiring anything beyond ordinary SQL: count(*) - count(col) for a NULL count
(count() ignores NULL, count(*) doesn't, so the difference
is exactly the NULL count); count(DISTINCT col) for cardinality; min(col)
/ max(col) for range, meaningful on any orderable type including SQLite's
TEXT dates. A real, non-quirk-spoiling profile of a few issues columns:
| Recipe | Column | Real result |
|---|---|---|
count(*) - count(col) | closed_at |
155 NULL — the still-open/in-progress issues |
count(DISTINCT col) | status |
3 — low cardinality, a GROUP BY candidate |
count(DISTINCT col) | project_id |
8 — matches the known project count exactly |
min(col) / max(col) | created_at |
2024-01-10 / 2025-05-23 |
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Low cardinality is the tell worth acting on: a 3-value column like status is cheap to
fully characterize with one GROUP BY, and the shape is worth memorizing before you go
looking for anything subtler —
$ sqlite3 workshop.db "SELECT status, count(*) FROM issues GROUP BY 1 ORDER BY 2 DESC;"
closed|345
open|103
in_progress|52
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
typeof(): what a column actually holds
Column types are enforced promises in most SQL databases. In ordinary (non-STRICT)
SQLite they're closer to a suggestion: a column's declared type only sets its affinity —
a recommendation for how to coerce an inserted value — and a value that can't be losslessly
converted is stored exactly as given anyway. Nothing about a CREATE TABLE statement
guarantees what typeof() will report for any given row.
[datatype3.html]
SQLite's own documentation demonstrates this with a query that pulls a single row through several
differently-affinitied columns and shows typeof() reporting a different storage class
for each, depending only on what was actually inserted — not what the column was declared as.
The mechanics: every affinity is derived from the declared type name by substring match
("INT" anywhere → INTEGER affinity, "CHAR"/"CLOB"/
"TEXT" → TEXT, "BLOB" or no type at all → BLOB, "REAL"/
"FLOA"/"DOUB" → REAL, otherwise → NUMERIC), and an INTEGER-affinity
column will store a string like 'high' as genuine TEXT storage the
moment it isn't a well-formed numeric literal — silently, with no error, on an ordinary table.
typeof(X) is how you catch this after the fact: it reports the value's actual storage
class, independent of whatever affinity its column has.
[datatype3.html]
STRICT tables (CREATE TABLE ... (...) STRICT, SQLite 3.37.0,
2021-11-27) are the opt-in fix — rigid, container-based typing instead of manifest typing.
workshop.db has none; a throwaway STRICT probe table confirms the
enforcement is real when you opt into it (an INTEGER-affinity STRICT column rejects a
text insert outright, framed differently per binary but the same underlying
SQLITE_CONSTRAINT either way) — covered already in §5.
[datatype3.html]
Finding interesting columns fast
Put the three techniques together and a quick triage emerges: run count(DISTINCT col)
across every column you don't already understand — anything low (single digits on a
500+-row table) is a GROUP BY candidate worth characterizing fully, the way
status was above. Run the NULL-count recipe on anything nullable — most come back
boring (a handful of missing emails, an expected "still open" gap), but an unusually high rate is
worth a second look. And run typeof() ... GROUP BY 1 on any numeric-looking column
you don't fully trust, especially one with no STRICT or CHECK backing it
— most will report exactly one storage class every time; a column reporting more than one is
telling you something a .schema read never would.
Goal: using only the recipes above, find workshop.db's two remaining planted
quirks — a column that's disproportionately NULL, and a column secretly holding more
than one storage class — and confirm which table is still the busiest by row count.
-
Refresh the row counts with the flagship loop (identical recipe, run directly against
workshop.dbthis time rather thanmystery.db):$ sqlite3 workshop.db "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'" \ | while read -r t; do echo "$t $(sqlite3 workshop.db "SELECT count(*) FROM \"$t\"")"; done13 rows back — 7 base tables plus the FTS5 internals, the same shape as §6's own 13-table listing — with
eventsstill the largest of the 7 base tables, by a wide margin over the next-biggest (comments). -
Run the NULL-rate recipe against
issues.assignee_id— both the raw count and the percentage form:$ sqlite3 workshop.db "SELECT count(*) - count(assignee_id) FROM issues;" $ sqlite3 workshop.db "SELECT round(100.0*sum(assignee_id IS NULL)/count(*)) FROM issues;"A single number each time — is it a handful of rows, or a substantial fraction of all 500 issues? Note both numbers before opening the answer key.
-
Run the
typeof()histogram againstissues.priority— declaredINTEGER, noSTRICT, noCHECK:$ sqlite3 workshop.db "SELECT typeof(priority), count(*) FROM issues GROUP BY 1 ORDER BY 2 DESC;"More than one storage class should come back — exactly the situation
typeof()exists to catch. Note how many distinct values appear, and which ones, before opening the answer key.
You should now see: events confirmed as the busiest table; a clearly
disproportionate NULL rate on one issues column; and a typeof()
histogram on another issues column that isn't just one storage class. Compare your
three numbers against the answer key below.
Stretch: profile one more column of your choosing the same way —
comments.author_id or events.actor_id are both nullable and worth a
NULL-rate check; neither turns up anything as dramatic as the two quirks above, which is itself a
useful data point (most columns really are boring).
Cleanup: none — every step in this lab reads workshop.db; nothing was created or
modified.
[local sqlite3 3.43.2, captured 2026-07-17]
Answer key
| Find | Answer |
|---|---|
| Busiest table | events — 5000 rows, 2.5× the next-largest
(comments, 2000) |
| NULL-heavy column | issues.assignee_id — 292 of 500 rows NULL
(round(100.0*sum(assignee_id IS NULL)/count(*)) → 58.0) |
| Mixed-affinity column | issues.priority (declared
INTEGER) — typeof(priority) histogram:
integer|341, null|113, text|46. The 46
text rows are the literal string 'high', stored exactly as given
because it isn't a well-formed numeric literal — INTEGER affinity only recommends, it
doesn't enforce. |
[local sqlite3 3.43.2, captured 2026-07-17]
Sources: Datatypes In SQLite · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
7 · Analysis & Query Plans
§6 found the interesting columns. This section is about asking real questions of
them — reproducibly, with the query saved rather than retyped — and about reading
EXPLAIN QUERY PLAN well enough to know whether SQLite is doing that efficiently.
Reader's assumed comfort with window functions and CTEs already; the commentary below is about
shell mechanics, not SQL semantics.
Making analysis reproducible: heredoc, .read, .once
§4 already verified the two building blocks a real analysis session composes:
piped stdin (echo 'SQL' | sqlite3 db — non-interactive, runs to EOF and exits) and
.read FILE (executes a saved .sql file's statements — verified printing
8 for SELECT count(*) FROM projects). A heredoc is nothing more than bash
handing multi-line stdin to that same piped form, so a whole analysis session — mode-setting
dot-commands and all — can be typed once and piped in directly, no separate file required:
$ sqlite3 -readonly workshop.db <<'SQL'
.headers on
.mode column
SELECT status, count(*) FROM issues GROUP BY 1 ORDER BY 2 DESC;
SQL
For anything worth re-running as the data changes, save it as a file instead —
.read analysis.sql — and reach for .once FILE when you want one
statement's result captured without redirecting everything after it: also already verified in
§4 (wrote a count(*) result to a file and left stdout untouched).
The three compose directly: write the query once into analysis.sql, then
sqlite3 -cmd ".once result.txt" workshop.db ".read analysis.sql" runs it and captures
the output in one line.
[cli.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
Four worked examples
All four ran with .timer on — per the version note below, only the Homebrew binary
actually prints a Run Time: line, but the result rows themselves were identical on
both. Each is exactly the kind of query worth saving as analysis.sql rather than
retyping at the prompt.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Issues closed per month, with a running total — a window function
(SUM(...) OVER (ORDER BY ym)) over a monthly GROUP BY. Worth capturing with
.mode list exactly as shown (pipe-separated, script-friendly) rather than a boxed table
you'd have to reformat before charting it:
$ sqlite3 workshop.db "SELECT strftime('%Y-%m', closed_at) AS ym, count(*) AS closed,
sum(count(*)) OVER (ORDER BY strftime('%Y-%m', closed_at)) AS running_total
FROM issues WHERE closed_at IS NOT NULL GROUP BY ym ORDER BY ym;"
2024-01|3|3
2024-02|6|9
2024-03|18|27
2024-04|20|47
2024-05|16|63
2024-06|25|88
...
Real output continues for 20 months total, ending 2025-08|4|345 — the full 345
closed issues accounted for.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Top-5 users by event count, with RANK() — worth running exactly
this way specifically because the real data has a tie, and RANK()'s tie behavior
(leaving a gap in the numbering equal to the tie size) is the kind of thing easy to get wrong from
memory and worth confirming against a live example instead:
$ sqlite3 workshop.db "SELECT actor_id, n, rnk FROM (
SELECT actor_id, count(*) AS n, RANK() OVER (ORDER BY count(*) DESC) AS rnk
FROM events WHERE actor_id IS NOT NULL GROUP BY actor_id
) ORDER BY rnk LIMIT 5;"
28|136|1
16|124|2
15|124|2
5|124|2
26|122|5
Three users tie at 124 events and all three correctly get rank 2; the next distinct value
(user 26, 122 events) jumps straight to rank 5 — RANK() counts the tied rows themselves
into the gap, unlike DENSE_RANK(), which would have given user 26 rank 3.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Per-project resolution time, approximate median via NTILE(4) —
partitioning by project_id and bucketing each partition's issues into quartiles by
days-to-close, then averaging the 2nd-quartile bucket, is a cheap approximate median that doesn't
need a separate percentile extension:
$ sqlite3 workshop.db "SELECT project_id, round(avg(days), 1) FROM (
SELECT project_id, julianday(closed_at) - julianday(created_at) AS days,
NTILE(4) OVER (PARTITION BY project_id
ORDER BY julianday(closed_at) - julianday(created_at)) AS q
FROM issues WHERE closed_at IS NOT NULL
) WHERE q = 2 GROUP BY project_id ORDER BY project_id;"
1|39.0
2|35.8
3|34.8
4|34.3
5|40.0
6|38.2
7|39.3
8|52.5
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Busiest issue by combined comments + events — a correlated subquery per row,
summed and sorted. The technique worth noting here is .once-ing the result straight to
a file the moment you find something worth keeping, rather than re-running the query later and
hoping the data hasn't shifted underneath you:
$ sqlite3 workshop.db "SELECT i.id,
(SELECT count(*) FROM comments c WHERE c.issue_id = i.id) AS n_comments,
(SELECT count(*) FROM events e WHERE e.issue_id = i.id) AS n_events,
(SELECT count(*) FROM comments c WHERE c.issue_id = i.id)
+ (SELECT count(*) FROM events e WHERE e.issue_id = i.id) AS total
FROM issues i ORDER BY total DESC LIMIT 5;"
358|6|20|26
8|4|21|25
123|9|15|24
20|9|15|24
368|6|18|24
Issue 358 wins outright at 26 combined; two issues (123 and 20) genuinely tie at 24. [local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Reading EXPLAIN QUERY PLAN: SCAN vs. SEARCH
EXPLAIN QUERY PLAN prefixed on any statement shows what SQLite intends to do without
running it. SCAN means a full-table walk — every row visited, in table or index order
— while SEARCH means only a subset of rows are visited, via an index lookup. A
SEARCH ... USING COVERING INDEX line is stronger still: the index alone holds every
column the query needs, so SQLite never touches the underlying table b-tree at all for those rows.
[eqp.html]
Pulling one issue's event breakdown by kind is a real, concrete case to check this
against: it means filtering events on issue_id, the exact column
§5's Lab 2 already flagged as declared with no REFERENCES
and no index. Before creating one:
$ sqlite3 workshop.db "EXPLAIN QUERY PLAN SELECT kind, count(*) FROM events WHERE issue_id = 42 GROUP BY kind;"
QUERY PLAN
|--SCAN events
`--USE TEMP B-TREE FOR GROUP BY
A full scan of all 5000 events rows, plus a temp b-tree to satisfy the
GROUP BY. Creating the missing index and re-running the identical
EXPLAIN QUERY PLAN:
$ sqlite3 workshop.db "CREATE INDEX idx_events_issue ON events(issue_id);"
$ sqlite3 workshop.db "EXPLAIN QUERY PLAN SELECT kind, count(*) FROM events WHERE issue_id = 42 GROUP BY kind;"
QUERY PLAN
|--SEARCH events USING INDEX idx_events_issue (issue_id=?)
`--USE TEMP B-TREE FOR GROUP BY
SCAN becomes SEARCH ... USING INDEX idx_events_issue (issue_id=?) — only
the matching rows for issue_id=42 are visited now; the GROUP BY still
needs its own temp b-tree either way, since nothing indexes kind. This exact
before/after text is byte-identical between binaries — no framing difference, no version note
needed here. The index was dropped again immediately after capture, restoring
workshop.db's planted no-index state for the rest of this guide.
[eqp.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
.eqp on makes this a standing habit rather than something you remember to type:
every subsequent query prints its plan before its results, automatically, until .eqp
off. Turning it on at the start of any analysis session costs nothing and catches an
unindexed filter — like events.issue_id above — the moment you first run the query
that needs it, not after it's already been slow in production.
[cli.html]
.timer and .stats: the other half of "is this fast?"
EXPLAIN QUERY PLAN tells you the shape of the work; .timer on and
.stats put real numbers on it. Both carry the same binary-specific catch already
demonstrated in §6 — enabling .timer on is silent on
stock, whichever query you run; on brew
the same query prints a real Run Time: line, as shown throughout this section.
.stats on goes further, printing a ~20-line memory/page-cache/step-count report after
every statement — mostly identical between binaries, but brew's
report carries one extra line stock's never does:
Temporary data spilled to disk:, worth watching specifically on the kind of
GROUP BY-without-an-index query the EQP capture above just flagged.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Goal: answer four real analytical questions against workshop.db, each with exactly
one window-function or CTE query — the same four shapes just worked through above, now as
questions rather than demonstrations.
-
Q1. Of the first six months with any issues closed, which single month closed the most, and what's the running total through the end of that month?
$ sqlite3 workshop.db "SELECT strftime('%Y-%m', closed_at) AS ym, count(*) AS closed, sum(count(*)) OVER (ORDER BY strftime('%Y-%m', closed_at)) AS running_total FROM issues WHERE closed_at IS NOT NULL GROUP BY ym ORDER BY ym LIMIT 6;"Six rows, month/count/running-total — read off the single largest count column and its paired running total before checking the answer key.
-
Q2. Does the top-5 ranking by event count have any ties, and if so, how does
RANK()number the row right after the tie?$ sqlite3 workshop.db "SELECT actor_id, n, rnk FROM ( SELECT actor_id, count(*) AS n, RANK() OVER (ORDER BY count(*) DESC) AS rnk FROM events WHERE actor_id IS NOT NULL GROUP BY actor_id ) ORDER BY rnk LIMIT 5;"Five rows — look for a repeated rank value, then check what rank number the next distinct row gets.
-
Q3. Comparing projects 1 and 8's typical (2nd-quartile) resolution time — which one resolves issues slower, and by roughly how many days?
$ sqlite3 workshop.db "SELECT project_id, round(avg(days), 1) FROM ( SELECT project_id, julianday(closed_at) - julianday(created_at) AS days, NTILE(4) OVER (PARTITION BY project_id ORDER BY julianday(closed_at) - julianday(created_at)) AS q FROM issues WHERE closed_at IS NOT NULL ) WHERE q = 2 AND project_id IN (1, 8) GROUP BY project_id ORDER BY project_id;"Two rows, one per project — subtract one from the other before checking the answer key.
-
Q4. Which single issue generates the most combined activity (comments + events), and what's the split between the two?
$ sqlite3 workshop.db "SELECT i.id, (SELECT count(*) FROM comments c WHERE c.issue_id = i.id) AS n_comments, (SELECT count(*) FROM events e WHERE e.issue_id = i.id) AS n_events, (SELECT count(*) FROM comments c WHERE c.issue_id = i.id) + (SELECT count(*) FROM events e WHERE e.issue_id = i.id) AS total FROM issues i ORDER BY total DESC LIMIT 1;"One row: issue id, comment count, event count, total — note all four numbers before checking the answer key.
-
Pick one of your four answers and make it reproducible the way this section taught: save its query as
analysis.sql, then run it two ways.$ sqlite3 workshop.db ".read analysis.sql" $ sqlite3 -cmd ".once busiest.txt" workshop.db ".read analysis.sql" $ cat busiest.txtIdentical output all three times — once printed to the terminal directly, once redirected into
busiest.txtby.once, then the file's contents shown to prove it landed there correctly.
You should now see: four real answers, each cross-checked against the worked examples above,
plus one of them captured to a file with .read/.once composed
together exactly as this section's opening recipe described.
Stretch: before cleaning up, run one more query straight at the prompt (not
via .once) and confirm output goes back to your terminal, not into
busiest.txt a second time — then cat busiest.txt once more and confirm
it's unchanged, still holding only the one capture. This is .once's "next statement
only" behavior, confirmed firsthand rather than taken on faith.
Cleanup: rm -f analysis.sql busiest.txt — nothing later in this guide depends on
either file.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Answer key
| Question | Answer |
|---|---|
| Q1 — biggest month + running total | 2024-06, 25 closed that
month, running total 88 through end of June |
| Q2 — tie handling | 3-way tie at 124 events (users 16, 15, 5) all rank 2; the
next distinct value (user 26, 122 events) jumps to rank 5, not rank 3 —
RANK() counts the tied rows into the gap |
| Q3 — project 1 vs. project 8 | project 8 is slower: 52.5 days vs.
project 1's 39.0 days, a gap of about 13.5 days |
| Q4 — busiest issue | issue 358 — 6 comments + 20 events = 26 total
combined activity |
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Sources: Command Line Shell For SQLite · EXPLAIN QUERY PLAN · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
8 · Export & Import
Everything so far has stayed inside the shell. This section is about getting data back out —
to a spreadsheet, to jq, to another SQLite file, to plain diffable text — and back
in again, plus the one distinction worth internalizing before §9: a binary copy
and a textual dump are not the same kind of backup, and they fail differently.
CSV export
The standard recipe is three dot-commands then one query: .headers on,
.mode csv, .once FILE, then the SELECT. A real capture of
exactly this against issues produced byte-identical file content on both binaries —
diff of the two produced .csv files reported no differences at all:
$ sqlite3 -cmd ".headers on" -cmd ".mode csv" -cmd ".once issues.csv" workshop.db \
"SELECT id, title, status FROM issues LIMIT 3;"
$ cat issues.csv
id,title,status
1,"Issue 1: crash on startup",in_progress
...
RFC 4180 quoting kicks in automatically on the comma-bearing title field — nothing
extra to configure. .once here is doing exactly the job §7
established: redirect one statement's output, then automatically revert.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
JSON, three ways
.mode json is the simplest: set it, run the query, get an array of objects back.
A real capture, identical on both binaries:
$ sqlite3 -cmd ".mode json" workshop.db "SELECT id, title, status FROM issues LIMIT 3;"
[{"id":1,"title":"Issue 1: crash on startup","status":"in_progress"}, ...]
json_group_array(json_object(...)) builds the identical shape from inside SQL itself,
as a single scalar value — useful when you want the JSON text as one row you can further process
with SQLite's own json_* functions, or hand to something that expects exactly one
value rather than a multi-row result set:
$ sqlite3 workshop.db "SELECT json_group_array(json_object('id', id, 'title', title, 'status', status))
FROM (SELECT id, title, status FROM issues LIMIT 3);"
Verified to produce identical JSON text to .mode json's output on both binaries. The
-json shell flag is the third form — sqlite3 -json workshop.db "SELECT
..." — and matches .mode json's shape too, on both binaries; reach for it when
you want JSON from a one-shot invocation without a separate -cmd.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
| Form | Reach for this when… |
|---|---|
.mode json | an interactive session or script where every subsequent query result should render as JSON |
json_group_array(json_object(...)) | you want the JSON as a single
SQL value — to nest it, further process it with json_*, or return exactly one row
regardless of output mode |
-json flag | a one-shot invocation that should emit JSON without a separate mode-setting command first |
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
.dump: full, single-table, and piped straight into a new database
.dump converts the database (or, given a table argument, just that table) into plain
SQL text — CREATE plus INSERT statements that reconstruct it exactly when
piped back into sqlite3. .dump issues was verified byte-identical between
binaries, CREATE TABLE and all 500 rows' INSERT text included, zero
diff output. The same holds for .dump users, and piping it straight into
a brand-new database is a real, working recipe for lifting one table out on its own:
$ sqlite3 workshop.db ".dump users" | sqlite3 users-only.db
$ sqlite3 users-only.db "SELECT count(*) FROM users;"
40
Verified round-tripping correctly on both binaries — the piped-into database ends up with exactly
the 40 rows users actually has.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
A full .dump (no table argument) carries one binary difference already established in
§3's Lab 0: brew prepends a leading
/* WARNING: Script requires that SQLITE_DBCONFIG_DEFENSIVE be disabled */ comment that
stock never emits. Everything after that first line is otherwise the
same kind of byte-identical text just demonstrated for the single-table form.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
.import: CSV round-trip, and a header-row footgun worth knowing
.import FILE TABLE's handling of the first input row depends entirely on whether
TABLE already exists. If it does not, the table is auto-created using
that first row as column names, and data starts from row two. If it already
exists, every row — including the first — is treated as data, which is exactly why
--skip N exists: skip the header explicitly when importing into a pre-existing table.
Both the modern form (--csv --skip 1) and the older flagless form behave identically
on both binaries — no version difference here, contrary to what you might expect given how much
else in this guide differs between stock and brew.
[cli.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
The auto-create and --skip behaviors stack if you're not careful:
importing into a table that doesn't exist yet already consumes the header row for column names —
adding --skip 1 on top of that skips an additional row, silently dropping the
first real data row. --skip is for a table that already exists (per the documented
behavior above); create the target table explicitly first, matching the CSV's columns, then
--csv --skip 1 does exactly what it looks like it should.
.excel
.excel is documented as a literal alias for .once -x: write the query
result as CSV into a temporary file, invoke the system's default handler for CSV files (typically
Excel or LibreOffice), then delete the temp file once that application has it open. Not run in this
environment — it launches a real GUI application with no scriptable exit, so it's documented here
from .help/cli.html text only, the same caveat already noted in
§4.
[cli.html]
.backup / .clone vs. .dump
.backup ?DB? FILE (an alias for .save) and .clone NEWDB are
file/binary-level operations — already used in Lab 1 to produce
mystery.db, a byte-faithful copy. .dump is categorically different:
plain SQL text, logical rather than binary. The WARNING-line and FTS5-shadow-table differences
already seen in this guide are proof that two different sqlite3 builds do
not produce identical dumps of identical data, even though both binaries round-trip that
same data correctly.
.backup / .clone | .dump | |
|---|---|---|
| Format | binary — an exact copy of the database file | plain SQL text — CREATE/INSERT statements |
| Cross-version identity | binary-identical, same file bytes regardless of which
sqlite3 build wrote it |
logical only — same data, but not byte-identical across binaries (WARNING-line,
FTS5-blob, and sqlite_stat4 differences already seen in this guide) |
| Readable / diffable | no — opaque binary | yes — plain text, line-diffable in any version control system |
| Portable to other engines | no — SQLite's own file format only | yes — the docs' own example pipes a dump into psql |
| Safe against a live, open database | yes, by design (that's the whole
point of .backup) — see §9 for why a plain cp
isn't |
only via the normal SQL layer's own consistency guarantees; not a substitute for a live-safe binary snapshot — §9's subject, not this section's |
| Reach for this when… | you need an exact, binary-identical copy | you need something you can read, diff, or hand to a different database engine entirely |
[cli.html]
.dump is the universal exchange format: plain text, line-diffable in any version
control system, and readable by literally any tool that can pipe SQL into a database — not just
another sqlite3 (the docs' own example pipes a dump into psql). A binary
.backup only round-trips through SQLite itself; a .dump outlives the
specific binary that produced it.
[cli.html]
Goal: get data out of workshop.db three different ways, and prove one of them
round-trips cleanly back in.
-
Export every issue's
id/title/statusto CSV — the recipe from above, without theLIMIT:$ sqlite3 -cmd ".headers on" -cmd ".mode csv" -cmd ".once issues.csv" workshop.db \ "SELECT id, title, status FROM issues;" $ wc -l issues.csv501— one header line plus all 500 issues. This file opens directly in any spreadsheet application (double-click, or.once -x/.excelto skip the intermediate file and open one automatically); nothing further to verify by hand once you see the row count line up. -
Export a JSON summary — status counts, not raw rows — the kind of small, self-describing payload worth handing to
jqor a script rather than a full CSV dump:$ sqlite3 -cmd ".once summary.json" workshop.db \ "SELECT json_group_array(json_object('status', status, 'n', n)) FROM (SELECT status, count(*) AS n FROM issues GROUP BY status);" $ cat summary.json | python3 -m json.toolA 3-element JSON array, one object per status — the identical
closed/open/in_progresscounts already established in §6, now in a shapejq '.[] | select(.status=="open")'could filter directly. -
Dump a single table into a fresh database — the exact recipe verified above:
$ sqlite3 workshop.db ".dump users" | sqlite3 users-only.db $ sqlite3 users-only.db "SELECT count(*) FROM users;" 4040— a complete, independent database containing nothing but theuserstable, built from text alone. -
Re-import step 1's CSV and verify the round trip. Create the target table explicitly first — the footgun above means skipping this would silently drop a row:
$ sqlite3 reimport.db "CREATE TABLE issues_reimport(id INTEGER, title TEXT, status TEXT);" $ sqlite3 reimport.db ".import --csv --skip 1 issues.csv issues_reimport" $ sqlite3 reimport.db "SELECT count(*) FROM issues_reimport;" 500500— matchingissues' real row count exactly, confirming the export/re-import round trip lost nothing.
You should now see: a full CSV export ready for a spreadsheet, a compact JSON summary shaped
for jq, an independent single-table database built from .dump text
alone, and a re-imported CSV whose row count matches the source exactly.
Stretch: before cleaning up, re-run step 4's import without
pre-creating issues_reimport — let .import --csv --skip 1 auto-create the
table instead — and compare the resulting count(*) against the 500 you
got above. The auto-create path already consumes the header row for column names before
--skip 1 skips one more, so whatever number comes back should be short of the real
total by exactly one row — the footgun from above, confirmed firsthand rather than taken on
faith.
Cleanup: remove every file this lab produced —
$ rm -f issues.csv summary.json users-only.db reimport.db
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Sources: Command Line Shell For SQLite · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
9 · Safety
Every command so far has been friendly: one reader, one writer, one sqlite3 process at
a time. Production databases are rarely that polite — another process may hold a lock, a background
job may be mid-write, and the file on disk may be in a state your shell session did not create. This
section is about that reality: what SQLite's locking model actually guarantees, what WAL mode changes
about it, and how to open, read, and back up a live database without becoming the reason it breaks.
The locking model, from the shell's point of view
Every connection — including a one-shot sqlite3 invocation — moves through a small
state machine as it reads and writes: UNLOCKED (no access; other processes have full
access), SHARED (many processes may hold SHARED at once to read, but none may
write), RESERVED (a single process intends to write soon, while existing SHARED
readers keep reading), PENDING (a would-be writer is waiting for every current
SHARED reader to clear — and, critically, no new SHARED lock is granted once a connection
holds PENDING), and EXCLUSIVE (required to actually write; no other lock of any kind
coexists with it).
[lockingv3.html]
SQLITE_BUSY is what a connection gets back when it fails to obtain a lock it needs
because another connection already holds a conflicting one: "If the SHARED lock cannot be obtained,
fail immediately and return SQLITE_BUSY," and "If the process that wants to write is unable to obtain
a RESERVED lock, it must mean that another process already has a RESERVED lock. In that case, the
write attempt fails and returns SQLITE_BUSY." Notice what the docs do not say: neither this
page, rescode.html, nor c3ref/busy_timeout.html ever spells out the
plain-English string a real sqlite3 session prints for this — that only shows up once
you actually trigger it, below.
[lockingv3.html]
Your shell session is just another client — the app's locks apply to you, and yours to it. Opening
workshop.db in a second terminal while a long transaction is open elsewhere doesn't earn
a "just looking" exemption; you queue behind the same SHARED/RESERVED/PENDING/EXCLUSIVE rules as any
other connection.
Reproducing "database is locked" for real
The obvious demo — run a writer in the background, sleep briefly, run a second writer — turned out
not to reproduce a lock: a one-shot CLI invocation's connection (and its
transaction) closes the instant its last statement finishes, which happens almost immediately, so the
"background" writer had already committed and released its lock before the second one ever ran.
Getting a genuine lock takes keeping the first writer's transaction open on purpose — piping its SQL
through a subshell that sleeps after printing it, so the CLI's interactive read
loop (and the transaction) stays alive until the sleep ends:
$ ( { printf 'PRAGMA busy_timeout=0;\nBEGIN IMMEDIATE;\nSELECT %s;\n' "'holding lock'"; sleep 2; } \
| sqlite3 workshop.db ) &
$ sleep 0.5
$ sqlite3 workshop.db "PRAGMA busy_timeout=0; INSERT INTO tags(name) VALUES('locked-probe')"
With this recipe, stock fails with
Error: stepping, database is locked (5) (5 = SQLITE_BUSY — the real-world
source of that exact string, which does not appear anywhere on sqlite.org's own docs pages);
brew fails with the same core message framed differently:
Error in 2nd command line argument: database is locked.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
PRAGMA busy_timeout = milliseconds; is the fix: it installs a busy handler that retries
a locked operation for up to the given time instead of failing immediately. Demonstrated for real —
same recipe, writer's PRAGMA busy_timeout=3000 instead of 0, holder
releasing its lock after 1.5s: the writer's INSERT blocked for ~1.3 real seconds, then
succeeded with no error at all. Each connection gets exactly one busy handler; setting this pragma
installs (and can overwrite) that connection's.
[pragma.html; local sqlite3 3.43.2,
captured 2026-07-17]
Journal modes: rollback vs. WAL
A fresh workshop.db reports PRAGMA journal_mode as delete —
the default rollback-journal mode, where SQLite writes changed pages' old content to a
-journal sidecar before touching the main file, then deletes that sidecar on commit
(§2's sidecar glob came back empty for exactly this reason). WAL mode inverts the arrangement: the
main file is left untouched at commit time, and committed changes are appended as "frames" to a
-wal sidecar instead — "a COMMIT occurs when a special record indicating a commit is
appended to the WAL," not by writing to the main database file at all. A second sidecar,
-shm (the wal-index, shared memory), holds an index that "helps readers locate pages in
the WAL quickly and with a minimum of I/O" — it isn't the log itself, just a fast lookup into it.
[wal.html]
Moving committed WAL frames back into the main file is called a checkpoint — the
mechanism that keeps -wal from growing forever. SQLite checkpoints automatically
whenever a commit pushes the WAL past roughly 1000 pages, and whenever the last open connection to
the file closes. PRAGMA wal_checkpoint(MODE) forces one on demand: PASSIVE
(the default, and the only mode automatic checkpointing uses) does as much as it can without blocking
anyone; FULL and RESTART "try harder" and will block (invoking the
busy-handler) until there's no writer and every reader is on the latest snapshot. Real capture on a
fresh workshop.db: journal_mode=WAL reports back wal;
wal_checkpoint(TRUNCATE) reports 0|0|0 (nothing to checkpoint yet);
switching back reports delete again — identical on both binaries. WAL mode is also
persistent once set: it survives close and reopen (unlike other journal modes, which revert to
DELETE), because WAL mode is a property of the file itself, applying to every connection once any one
sets it. This guide's own ground truth confirmed the mode switch and checkpoint output above but never
separately captured a directory listing (ls workshop.db*) showing the -wal/
-shm sidecar files actually present on disk while in that mode — flagged here rather than
asserted from memory; what is real, captured ground truth is the pragma output above and the
round-trip page-count DIFF just below.
[wal.html;
pragma.html; local sqlite3 3.43.2+3.53.3, captured
2026-07-17]
That round trip isn't equally clean on both binaries, though — a real, verified DIFF, not a
formatting quirk. Cycling an identical copy through journal_mode=WAL →
wal_checkpoint(TRUNCATE) → journal_mode=DELETE, with zero writes in
between: stock's copy grew from 156 to 169 pages (13
leftover freelist pages, +53,248 bytes) and left a stray -shm file behind even after
returning to journal_mode=DELETE; brew's copy returned to
exactly 156 pages, 0 freelist pages, its original byte size, with no stray sidecar files at all.
Merely cycling stock's sqlite3 through WAL mode and back — touching no data at all —
permanently grows the file until a VACUUM (§10) cleans it up.
[local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
-wal; readers never block on
it because they read a consistent snapshot via the -shm index; only a checkpoint
(automatic, or forced with PRAGMA wal_checkpoint) moves committed frames back into the
main file.
[wal.html]Reading a live database safely
§2 already established the baseline: -readonly (or the equivalent
file:...?mode=ro URI) makes a write fail instead of silently landing — real capture,
both forms, both rejected, tags's row count unchanged before and after. The
file: URI's mode parameter takes ro, rw,
rwc (read-write, create if missing), or memory; any scheme other than
file: just makes the whole string an ordinary filename instead of a URI (recognized
since SQLite 3.15.0, 2016-10-14).
[uri.html]
?immutable=1 goes further — it tells SQLite the file is on read-only media and will
never change, even by another process, letting it skip locking and change-detection entirely for a
real performance win. That guarantee is enforced by trust, not verified: the docs are explicit that
if the underlying content changes anyway, SQLite may return incorrect results or
SQLITE_CORRUPT errors. Real capture: SELECT count(*) FROM tags through
file:workshop.db?immutable=1 correctly returns 12 on both binaries — but
that's only safe because nothing was writing to the file at the same time. Reach for
immutable=1 only on a genuinely static file (an archived snapshot, a bundled reference
db); reach for plain -readonly / mode=ro for anything another process might
still be writing.
[uri.html; local sqlite3 3.43.2+3.53.3,
captured 2026-07-17]
.backup vs. a plain cp — and where --safe fits
Never back up a live database with a plain cp of just the main file. It's safe to
copy an SQLite database "as long as there are no transactions in progress while the copy is taking
place" — but a live WAL-mode database is exactly the case where that's easy to get wrong: "if a
database file is separated from its WAL file, then transactions that were previously committed to
the database might be lost, or the database file might become corrupted." Demonstrated for real: with
a WAL-mode copy holding one committed INSERT that hadn't been checkpointed yet, a naive
cp of the main file alone produced a copy where
SELECT count(*) FROM tags WHERE name='wal-only-row2' returned 0 — the
committed row existed only in the -wal file cp never touched.
.backup, run against that same live, still-open source, correctly returned
1. Safe alternatives that work even on a live database: .backup (§8),
VACUUM INTO (§10), or sqlite3_rsync (SQLite 3.47.0+).
[howtocorrupt.html; wal.html; local sqlite3 3.43.2, captured 2026-07-17]
§4 already covered --safe, the CLI's own least-privilege mode
(disables .open, .shell/.system, and everything else that
could touch the outside world). It belongs in this conversation for the same reason
-readonly does: both narrow what your own shell session is allowed to do to a
file, rather than trusting yourself not to make a mistake. The one register-backed DIFF there —
stock's line 0: cannot run .shell in safe mode vs.
brew's argv[3]: cannot run .shell in safe mode — is the same
message-framing pattern as the locked-database error above, not a new one.
Sources: Write-Ahead Logging · File Locking And Concurrency In SQLite Version 3 · How To Corrupt An SQLite Database File · Uniform Resource Identifiers · PRAGMA Statements · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
10 · Maintenance
A database that opens and answers queries correctly today can still be quietly bloated, statistically stale, or one bad byte away from failing tomorrow. This section covers the maintenance side of stewardship: checking a file's integrity, accounting for its size, keeping the query planner honest, and — when integrity checking says something is actually wrong — how much of a corrupted file you can realistically get back.
Integrity checks: integrity_check vs. quick_check
PRAGMA integrity_check (optionally integrity_check(N) or
integrity_check(TABLENAME)) is a low-level scan for out-of-sequence entries, misformatted
records, missing pages, missing/surplus index entries, constraint violations, freelist problems, and
doubly-used or unused sections of the file. On a healthy database it returns exactly one row, the
literal string ok — real capture, both binaries, against the pristine
workshop.db:
$ sqlite3 workshop.db "PRAGMA integrity_check;"
ok
PRAGMA quick_check runs the same kind of scan but skips the UNIQUE-constraint and
index-vs-table cross-checks, making it O(N) instead of O(N log N) in total row count — a cheaper "is
this file grossly broken" sanity check when a full integrity_check would be too slow. It
also reports ok here. Neither catches FOREIGN KEY violations —
PRAGMA foreign_key_check is the tool for that. Lab 6 below shows what both look like
against a database that isn't healthy.
[pragma.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
Size accounting: page_size × page_count, and where freed space goes
PRAGMA page_size, page_count, and freelist_count are the three
numbers behind every SQLite file's size on disk: page_size × page_count is the file's
real byte size, and page_size × freelist_count is how much of that is dead weight already
reclaimable by VACUUM. §2 already confirmed the pristine numbers: 4096-byte pages, 156
pages, 0 freelist pages — 4096 × 156 = 638,976, matching ls -l workshop.db
exactly.
[pragma.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
Deleting rows doesn't shrink a file — it just marks pages reusable. Real capture, on a copy:
DELETE FROM events WHERE id > 4000 leaves page_count unchanged at
156 but pushes freelist_count to 10, on both binaries. Running
VACUUM on that same copy rebuilds the whole file — 0 freelist pages,
144 total pages, the file dropping to 589,824 bytes, again identical on
both. VACUUM INTO 'compact.db' — a separate, non-destructive snapshot that leaves the
source file untouched — produced a 634,880-byte file with all 500 issues
rows intact; that number is real but doesn't line up exactly with the in-place VACUUM
figure just above, and this guide's own ground truth doesn't reconcile the small discrepancy further
— flagged here rather than explained away.
[lang_vacuum.html; local sqlite3
3.43.2+3.53.3, captured 2026-07-17]
VACUUM needs up to twice the file's size in free disk space (it builds the new copy
before discarding the old), fails if the connection running it has an open transaction, and can
change the ROWID of any row in a table without an explicit INTEGER PRIMARY KEY
— worth knowing before running it against a table some external code has cached rowids from.
[lang_vacuum.html]
ANALYZE, sqlite_stat1, and PRAGMA optimize
ANALYZE gathers statistics about tables and indexes and stores them where the query
planner can use them to pick better plans — most valuable after a bulk load or schema change, and
exactly what makes an EXPLAIN QUERY PLAN choice like §7's SCAN→SEARCH swap reflect real
table shape rather than a guess. The default implementation stores everything in one table,
sqlite_stat1. Real capture: ANALYZE followed by
SELECT * FROM sqlite_stat1 LIMIT 5 returns 5 rows of the identical 3-column shape
(tbl|idx|stat) on both binaries — but the specific 5 rows differ between runs, which is
expected and not a version DIFF: the query has no ORDER BY, and
sqlite_stat1 has no defined row order. (A separately captured example row,
issues|idx_issues_project|500 63, shows the shape concretely — the first number in
stat is the table's approximate row count.) PRAGMA optimize ran silently —
no output, exit 0 — on both.
[lang_analyze.html; local
sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Running ANALYZE by hand is no longer the recommended workflow: applications with
short-lived connections should run PRAGMA optimize; once, just before closing each
connection (long-lived connections should run PRAGMA optimize=0x10002; once right after
opening, then plain PRAGMA optimize; periodically). PRAGMA optimize "is
usually a no-op but it will occasionally run one or more ANALYZE subcommands" when it judges statistics
are stale or missing — and since SQLite 3.46.0 it automatically limits their scope so the command
stays fast even on a large database.
[lang_analyze.html]
Maintenance is workload-dependent, not automatic hygiene. If auto_vacuum is enabled,
free pages get reclaimed after deletes without a full VACUUM — running VACUUM
on a cron schedule against such a database is often cargo-cult. But auto_vacuum doesn't
compact partially-filled pages the way VACUUM does, and it can leave a file more
fragmented over time — so a database that's still slow or bloated despite auto_vacuum
can still benefit from an occasional explicit VACUUM. The same goes for
ANALYZE: reaching for it by hand every time is the old habit; PRAGMA optimize
at connection close is the modern one.
[lang_vacuum.html;
lang_analyze.html]
Goal: corrupt a copy of workshop.db on purpose, watch integrity_check
catch it, then find out — honestly — how much of it a salvage actually recovers.
Everything in this lab runs against copies. Never point a corruption experiment or a recovery
attempt at a database you or anyone else actually depends on — and never at the shared
workshop.db this guide uses everywhere else.
-
Make a disposable copy and break it — 32 bytes of
0xdeadbeef, written 40 bytes into page 2 (offset4096 + 40):$ cp workshop.db corrupt.db $ python3 - <<'EOF' with open('corrupt.db', 'r+b') as f: f.seek(4096 + 40) f.write(b'\xde\xad\xbe\xef' * 8) EOF -
Confirm the damage — the first line of defense before touching anything else:
$ sqlite3 corrupt.db "PRAGMA integrity_check;" 2>&1 | head -5 *** in database main *** Tree 2 page 2 cell 31: Offset 48879 out of range 2556..4080 Tree 2 page 2 cell 30: Offset 57005 out of range 2556..4080 ...Real, immediate cell-offset errors — 4 of them — on the very first try, on both binaries identically. (This exact offset was the one this guide's own ground-truth pass used; if you land on a page that happens to be free, nudge the offset by one page size and try again.)
-
Try the obvious salvage recipe first —
.recoverscans pages directly instead of going through the normal SQL layer, so it's supposed to survive exactly this kind of damage:$ sqlite3 corrupt.db ".recover" | sqlite3 rescued.db $ sqlite3 rescued.db "SELECT count(*) FROM users;"Not
40. On both binaries this naive one-liner produces a completely emptyrescued.db— zero tables at all, so that finalSELECThas nothing to query. This is the real footgun, not a hypothetical one. -
Find out why: run
.recoverunpiped and look at its raw output. Partway through reconstructingissues, the page scan hits the corrupted cell and prints a literal diagnostic string inline, mid-statement, with no surrounding newline —...'status', 'prioritysql error: (null) (25)(25 =SQLITE_RANGE) — then keeps going on the next physical line. The script never reaches a finalCOMMIT;after that glitch, so the receivingsqlite3 rescued.dbprocess rolls back its entire transaction on exit — everyCREATE TABLE/INSERTthat printed cleanly before the glitch is thrown away along with the broken line. It can look like hundreds of lines of valid recovery SQL streaming past and still leave you with nothing.The older
.dump-based salvage has the same footgun for a different reason:sqlite3 corrupt.db .dumpruns to full completion — every table's SQL text present, including a clean-looking row for the very issue that broke.recover— but ends withROLLBACK; -- due to errorsinstead ofCOMMIT;, because it detected the corruption internally and refused to commit. Piping it straight into a new db hits the identical empty-database result. -
Salvage it properly: capture
.recover's output to a file, manually strip the one injectedsql error: (null) (25)fragment so the split statement rejoins correctly, add a trailingCOMMIT;, then load the patched file into a fresh database.$ sqlite3 corrupt.db ".recover" > recover.sql $ # edit recover.sql: delete the injected "sql error: (null) (25)" fragment, $ # confirm/append a trailing COMMIT; $ sqlite3 rescued-patched.db < recover.sql $ sqlite3 rescued-patched.db "SELECT count(*) FROM issues; SELECT count(*) FROM users;"issues: 500 / 500 — fully recovered, despite.recoverchoking mid-generation on exactly this table.users: 16 / 40 — recovered less than half, even though theuserstable's own pages were never touched by the corruption. Recovery is lossy and silent about the loss: a table with zero direct damage can still lose most of its rows as collateral damage from corruption elsewhere in the file, with nothing in the recovered SQL itself warning you — only comparing row counts against a known-good source reveals it. -
One more check worth running on the patched result:
$ sqlite3 rescued-patched.db "PRAGMA integrity_check;"stock's patched, rescued db reports
ok; brew's check halts outright instead of reachingokat all — a hard failure, not a supplementary line:invalid fts5 file format (found 0, expected 4 or 5) - run 'rebuild', nonzero exit. The two rescued databases were reconstructed from each binary's own, slightly different.recoverscript text, so this reflects a real reconstruction difference, not a cosmetic one.
You should now see: a corrupted copy that fails integrity_check with real
cell-offset errors, a naive .recover-piped-straight-in attempt that produces a totally
empty database despite looking like it worked, and — only after manually patching the output — a
partial, honestly-incomplete recovery: issues whole, users less than
half.
Stretch: before cleaning up, confirm the .dump-based salvage path
hits the identical footgun from a different tool:
sqlite3 dump-salvage.db < <(sqlite3 corrupt.db .dump), then
sqlite3 dump-salvage.db "SELECT count(*) FROM users;" — expect
no such table: users, the same silent-empty-database result as step 3 above, this time
via the older tool.
Cleanup — none of this touched the shared workshop.db, but remove every file this lab
produced:
$ rm -f corrupt.db rescued.db rescued-patched.db recover.sql dump-salvage.db
[cli.html; local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Goal: feel the effect of a real index on a real query — plan shape, and (where your binary shows
it) timing — then restore workshop.db's planted no-index state exactly as you found it.
-
Turn the timer on, then run §7's events-by-issue query as-is — no index exists on
events.issue_idyet:$ sqlite3 -cmd ".timer on" workshop.db \ "EXPLAIN QUERY PLAN SELECT kind, count(*) FROM events WHERE issue_id = 42 GROUP BY kind;" QUERY PLAN |--SCAN events `--USE TEMP B-TREE FOR GROUP BYThe plan text — a full
SCANof all 5000eventsrows, plus a temp b-tree for theGROUP BY— is byte-identical on both binaries, already confirmed in §7..timer onis not: stock stays silent, never printing aRun Time:line for any query; brew prints one after every statement. This guide's ground truth didn't separately capture a millisecond figure for this specific query's before/after — only the general on/off split above — so if you're on brew, treat your ownRun Time:line here as the real "before" number, not a value quoted from this page. -
Create the index §5's Lab 2 already flagged as missing, then re-run the identical query:
$ sqlite3 workshop.db "CREATE INDEX idx_events_issue ON events(issue_id);" $ sqlite3 -cmd ".timer on" workshop.db \ "EXPLAIN QUERY PLAN SELECT kind, count(*) FROM events WHERE issue_id = 42 GROUP BY kind;" QUERY PLAN |--SEARCH events USING INDEX idx_events_issue (issue_id=?) `--USE TEMP B-TREE FOR GROUP BYSCANbecomesSEARCH ... USING INDEX idx_events_issue (issue_id=?)— again byte-identical text on both binaries. If you're on brew, compare this run'sRun Time:line against step 1's; only visiting the matching rows instead of all 5000 should show up as a real, if probably small, drop. -
Stretch: before you drop the index, rerun the query with a different literal —
issue_id = 100instead of42— and confirm the plan text is identical. The plan itself is reported as the parameterizedissue_id=?, not a specific value, so any concrete literal takes the sameSEARCHpath; that's a property of the plan, not something this guide had to re-verify per value. -
Put the workshop back exactly how every other section of this guide expects to find it —
events.issue_idunindexed:$ sqlite3 workshop.db "DROP INDEX idx_events_issue;" $ sqlite3 workshop.db ".indexes events"No output —
.indexes eventsconfirms empty again, the planted no-index quirk restored, matching exactly what §5's Lab 2 and §7 both found.
You should now see: the same SCAN→SEARCH plan text §7 already showed
you, this time typed by your own hands; .timer on behaving true to its binary-specific
split; and workshop.db back in its original, unindexed state.
Closing note: this is exactly why the ANALYZE/PRAGMA optimize habit
described above matters in a real, growing database — an index only helps if the planner's statistics
about it stay current. Creating idx_events_issue and never running
ANALYZE/optimize again after the table doubles in size is how a good index
quietly stops getting chosen.
[eqp.html; local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Sources: PRAGMA Statements · VACUUM · ANALYZE · Command Line Shell For SQLite · EXPLAIN QUERY PLAN · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
11 · Helper Tools Tour
Everything in this guide so far works with nothing but the sqlite3 binary itself — that
was the point: it's the one tool guaranteed to be sitting on, or reachable from, almost any machine
that has SQLite at all. The five tools below aren't prerequisites for anything this guide taught;
they're power-ups worth reaching for once you know what plain sqlite3 doesn't do easily,
each trading "one more thing to install" for a real, specific capability.
Commands in this section are doc-sourced, not machine-verified like the rest of the guide. None of these tools were installed or run for this guide — every install line, command, and tradeoff below comes from each project's own documentation, cited inline, not from a local capture.
sqlite-utils — pip install sqlite-utils. Signature trick:
sqlite-utils query db.db 'SELECT ...' returns JSON by default, not sqlite3's
pipe-delimited list mode — pipe straight into jq with no .mode-equivalent
flag needed. Beats plain sqlite3 for sqlite-utils insert db.db table data.csv,
which auto-creates the destination table with automatically-detected column types straight from the
file's shape — .import requires the table to already exist, or falls back to naive
text-typed columns. Honest tradeoff: a separate Python dependency, and JSON-by-default is often the
wrong shape for eyeballing a wide table compared to sqlite3's box/column modes — built
for scripting and JSON pipelines, not interactive poking-around.
[sqlite-utils: CLI
reference]
litecli — pip install litecli, then litecli
db_name (also brew install litecli via the dbcli tap). Signature
trick: context-sensitive autocompletion and syntax highlighting at the prompt — type a partial
table/column name and get real completions, plus colorized SQL. Beats plain sqlite3 for
interactively exploring an unfamiliar schema, where the stock shell's no-completion, monochrome REPL
slows you down. Honest tradeoff: same SQL engine underneath, same feature set as sqlite3
(no new capability, nothing like a .recover it doesn't already have) — strictly an
interactive-typing convenience, not a scripting or automation tool.
[litecli]
VisiData — pip3 install visidata (or brew install
visidata). Signature trick: vd mydb.db opens the database as a full-screen,
keyboard-driven spreadsheet — every table becomes an interactive, scrollable, sortable, filterable
grid, not one static result set at a time. Beats plain sqlite3 for spreadsheet-style
exploration: sorting, filtering, and pivoting a table live, without writing SQL for each look. Honest
tradeoff: its own modal keybinding set to learn (not SQL — a separate command language for
sort/filter/pivot), and it's a separate Python tool with its own dependency footprint.
[VisiData: Loading Data]
Datasette — pip install datasette (or brew install
datasette). Signature trick: datasette path/to/database.db starts a local web
server (port 8001 by default) with a browsable UI — and the identical data is simultaneously a JSON
API, since appending .json to any table URL returns its rows as structured JSON. Beats
plain sqlite3 for sharing or browsing a database over HTTP — point-and-click table
browsing plus an API, for free. Honest tradeoff: built for sharing/browsing, not fast single-shot
terminal queries — spinning up a web server is real overhead next to a one-line
sqlite3 db.db "select ...", and it's another Python install with its own dependency
footprint.
[Datasette:
Getting Started]
DuckDB — brew install duckdb, or the universal installer
curl https://install.duckdb.org | sh. Signature trick: ATTACH 'workshop.db' (TYPE
sqlite); inside a DuckDB session reads the SQLite file's tables directly at query time — no
import/copy step — or open it as DuckDB's own file directly with duckdb workshop.db.
Beats plain sqlite3 for heavy analytics: DuckDB's columnar, vectorized engine is built
for exactly the complex aggregations, joins, and analytical scans SQLite's row-at-a-time engine
handles less efficiently at scale. Honest tradeoff: reading through the SQLite scanner is still bound
by SQLite's on-disk row format and I/O characteristics — not magically as fast as DuckDB's native
columnar storage — plus its own install footprint and its own SQL dialect quirks.
[DuckDB:
SQLite Extension]
Task → best tool
| I want to… | Reach for |
|---|---|
| Run one quick query with zero new dependencies | plain sqlite3 — the floor
this whole guide is built on |
| Auto-type and load a CSV/JSON file as a new table | sqlite-utils insert |
Pipe query results straight into jq/JSON tooling | sqlite-utils
query |
| Explore an unfamiliar schema interactively, with autocomplete | litecli |
| Scroll/sort/filter a big table like a spreadsheet | VisiData |
| Share a browsable UI + JSON API for a database | Datasette |
| Run heavy analytical joins/aggregations, or combine with other data sources | DuckDB (ATTACH ... (TYPE sqlite)) |
Sources: sqlite-utils: CLI reference · litecli · VisiData: Installation · Datasette: Installation · DuckDB: Installation
12 · Troubleshooting Table
A field-guide version of everything above: the symptom you actually see, what it really means, and the fastest real command to confirm it before you act.
| Symptom | Likely diagnosis | Fix / next command |
|---|---|---|
Error: stepping, database is locked (5) / database is locked |
Another connection holds a conflicting lock — a concurrent writer, or a session someone forgot to close. §9 reproduced this exact error live, on purpose. | PRAGMA busy_timeout = 3000; before retrying — demonstrated for real in §9 to turn
an instant failure into a ~1.3s wait, then success. If it never clears, find and close the
long-lived session holding the lock. |
Opening a .db-named file fails immediately, or every query on it errors out |
The file isn't a real SQLite database at all — wrong path, an empty/truncated file, or a
plain-text file wearing a .db extension. This guide's own ground truth never
separately captured the shell's exact runtime wording for this case — flagged here rather than
asserted verbatim; what is real, captured ground truth is catching the impostor before
sqlite3 ever opens it, below. |
file suspect.db — checks the real 16-byte magic header, not the extension. §3's
Lab 1 caught a plain-text impostor named not-a-db.db exactly this way, reporting
ASCII text instead of SQLite 3.x database, before sqlite3
was ever invoked on it. |
A query against the file errors out oddly, or integrity_check reports real
problems |
Genuine on-disk corruption — a torn write, a bad copy, or actual bit damage. (SQLite's
well-known "malformed disk image" wording for this class of error was not separately captured or
found verbatim in this guide's own sources — flagged rather than asserted as fact-checked; what
is real, captured ground truth is the integrity_check output shown next.) |
PRAGMA integrity_check; to characterize it — §10/Lab 6's real capture: 4
cell-offset errors (Tree 2 page 2 cell 31: Offset 48879 out of range 2556..4080, ...)
from a single corrupted page — then .recover, patched by hand per Lab 6's warning, for
whatever partial recovery is possible. Never trust a naive .recover | sqlite3 new.db
pipe without checking for a trailing COMMIT;. |
no such table: X |
Rarely a truly missing table — usually a wrong file path (a same-named db in another
directory), the wrong sqlite3 binary's working directory, or the table living in a
different attached database than the one you're querying. (This guide's own real captured instance
of this exact string, no such table: users, came from Lab 6's naive salvage footgun —
a completely empty recovered database, not a wrong path — a reminder the same error text can have
very different root causes.) |
.databases (or PRAGMA database_list, §5) lists every attached
database and its real file path — confirm you're pointed at the file and schema you think you are
before suspecting the table itself is gone. |
| A dot-command this guide uses fails with an unrecognized-command / usage error | An older or differently-built sqlite3 binary that doesn't have that command at
all — most likely the MacPorts-shadow trap §3 already flagged: bare sqlite3 on this
machine resolves to MacPorts 3.35.5, neither the stock (3.43.2) nor Homebrew (3.53.3) build this
guide verifies against, and each binary really does have commands the other lacks (register: stock
has .rekey/.hex-rekey/.text-rekey/.show; brew
has .load/.imposter/.intck/.dbtotxt/
.crlf/.www/.testcase). |
command -v sqlite3; sqlite3 --version — confirm which binary you actually got,
then invoke the right one by full path (/usr/bin/sqlite3,
$(brew --prefix sqlite)/bin/sqlite3) instead of bare sqlite3. §3. |
| The file is huge and every obvious command seems to hang or scroll forever | Accidentally materializing the whole table — a bare SELECT *, or a full
.schema/.dump on a database with thousands of objects — instead of
sampling it. |
Open read-only first (§9: sqlite3 -readonly or file:big.db?mode=ro,
add immutable=1 only if nothing else can write to it), run .dbinfo before
a single SELECT to size it up (§2), then keep every exploratory query under a
LIMIT. For a row-count estimate on a table too large to count(*)
comfortably, sqlite_stat1's stat column already holds one — real capture:
issues|idx_issues_project|500 63, where 500 is issues' real
row count, gathered by the last ANALYZE rather than a fresh full scan. |
Stray -wal, -shm, or -journal files sitting next to a
.db after a crash |
A "hot" journal or WAL SQLite left behind because the last transaction never completed cleanly — crash recovery is still pending, not already done. | Do not delete these by hand — "if the hot journal files are moved, deleted, or
renamed after a crash or power failure, then automatic recovery will not work and the database may
go corrupt." Just open the database normally with sqlite3; a clean open replays and
clears the hot journal/WAL for you. |
[howtocorrupt.html; cli.html; local sqlite3 3.43.2+3.53.3, captured 2026-07-17]
Sources: PRAGMA Statements · How To Corrupt An SQLite Database File · Command Line Shell For SQLite · local sqlite3 3.43.2 / 3.53.3 ground truth, captured 2026-07-17
13 · Capstone: Audit a Real Database
Everything above was demonstrated against one purpose-built workshop.db. This lab turns
the whole guide into a ten-step, printable checklist for auditing any real .db
file you're handed — your own project's database is the intended target. Work through it once, top to
bottom, and you have an audit note you could paste directly into an issue.
Goal: apply this guide's full workflow, in order, to one real database file of your choosing
(call its path $DB), and end with a pasteable audit note. Every command below already
appeared in §2–§10 — nothing here is new.
-
Identify it, and scan for sidecars. Confirm the magic header before trusting the name, and check for stray WAL/journal companions:
$ file "$DB" $ ls -l "$DB" "$DB"-wal "$DB"-shm "$DB"-journal 2>/dev/null(§2)
-
Confirm which binary you're about to run everything through. Bare
sqlite3is not a safe assumption on every machine:$ command -v sqlite3; sqlite3 --version(§3)
-
Open it so you can't break it. Every remaining step in this checklist reads through a read-only connection:
$ sqlite3 -readonly "$DB" "SELECT 1;"(§9)
-
.dbinfoplus the size-accounting math. A 21-line status read, then confirm it against the file's real byte size:$ sqlite3 -readonly "$DB" ".dbinfo" $ sqlite3 -readonly "$DB" "PRAGMA page_size; PRAGMA page_count; PRAGMA freelist_count;"page_size × page_countis the real on-disk size;page_size × freelist_countis how much of that is already reclaimable byVACUUM. (§2, §10) -
Map the schema, and sanity-check foreign keys. A human-readable schema dump, then a foreign-key pass per table — watch for a relationship that's real but undeclared, the way
events.issue_idwas:$ sqlite3 -readonly "$DB" ".schema --indent" $ sqlite3 -readonly "$DB" "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'" \ | while read -r t; do echo "== $t =="; sqlite3 -readonly "$DB" "PRAGMA foreign_key_list($t)"; done(§5)
-
Row counts for every table, then profile the three biggest. The flagship loop, then a NULL-rate and
typeof()pass on each of the three tables with the highest count:$ sqlite3 -readonly "$DB" "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%'" \ | while read -r t; do echo "$t $(sqlite3 -readonly "$DB" "SELECT count(*) FROM \"$t\"")"; done $ sqlite3 -readonly "$DB" "SELECT typeof(col), count(*) FROM biggest_table GROUP BY 1 ORDER BY 2 DESC;"(§6)
-
integrity_check— on a copy, never the live file.Never run
integrity_check, or anything else, against a naivecpof a live database — a plain file copy taken mid-transaction can capture a torn mix of old and new content, and on a WAL-mode database can miss committed rows entirely. Take a.backupcopy first, exactly as demonstrated in §9, then check the copy — never the original.$ sqlite3 -readonly "$DB" ".backup audit-copy.db" $ sqlite3 -readonly audit-copy.db "PRAGMA integrity_check;"(§10)
-
Review indexes against your hottest query with
EXPLAIN QUERY PLAN. Run the query you actually care about, prefixed withEXPLAIN QUERY PLAN— look for an unexpectedSCANwhere you expected aSEARCH:$ sqlite3 -readonly "$DB" "EXPLAIN QUERY PLAN <your hottest query here>;"(§7)
-
Freshness check — has
ANALYZEever actually run? An emptysqlite_stat1means the planner is flying blind:$ sqlite3 -readonly "$DB" "SELECT count(*) FROM sqlite_stat1;" $ sqlite3 -readonly "$DB" "SELECT * FROM sqlite_stat1 LIMIT 3;"Zero rows means
ANALYZE/PRAGMA optimizehas never run against this database; any rows back confirm the shape (tbl|idx|stat). (§10) -
Export a versioned snapshot. Plain SQL text, diffable and engine-portable, into your archive:
$ sqlite3 -readonly "$DB" ".dump" > "audit-$(date +%F).sql"(§8)
You should now have: a confirmed file identity and binary, a read-only-verified connection, a
size/health snapshot, a schema map with any undeclared relationships flagged, a row-count and
profile of the three busiest tables, an integrity-checked backup copy, an index review against your
real hottest query, an ANALYZE-freshness read, and a versioned .dump — ten
lines you could paste directly into an issue as this database's audit note.
Cleanup: remove the working copy once you've read its integrity_check result —
rm -f audit-copy.db. Keep the .dump snapshot; archiving it is the point of
step 10.
Sources: every command above restates a recipe already sourced in §2, §3, §5, §6, §7, §8, §9, and §10 above — no new source citations.
14 · Cheatsheet
Every command in this guide, grouped and dense, in the shape you'll actually reopen this page for. Each group header links back to the section that taught it in full; a stock or brew chip appears only where that exact command's teaching section already carried one — no new comparisons introduced here.
Identify & open
| Command | Purpose |
|---|---|
file db.db |
Confirms the real 16-byte magic header before trusting a name or extension. |
ls -l db.db db.db-wal db.db-shm db.db-journal 2>/dev/null |
Real byte size, plus a scan for stray WAL/journal sidecars. |
command -v sqlite3; sqlite3 --version |
Confirms which binary bare sqlite3 actually resolves to. |
sqlite3 -readonly db.db |
Opens without risking a write. Rejection message framed differently per binary:
stock Error: stepping, ...(8) /
brew Error in Nth command line argument: .... |
sqlite3 'file:db.db?mode=ro' |
Same read-only guarantee, URI form — safe on a database another process might still write. |
sqlite3 'file:db.db?immutable=1' |
Skips locking and change-detection entirely — only for a genuinely static file. |
.dbinfo |
A 21-line status read before a single SELECT. |
PRAGMA compile_options |
Real build/feature differences. stock 70 options / brew 62. |
Modes & output
| Command | Purpose |
|---|---|
-cmd ".mode X" db "SQL;" |
Combines a dot-command with one-shot SQL — the reliable form; one crammed positional argument fails on both binaries. |
.mode column |
Auto-sized terminal table. stock left-aligns numerics / brew right-aligns. |
.mode box |
Unicode-bordered table. stock single-line corners / brew rounded corners + double-line header rule. |
.mode json / -json |
Array-of-objects — byte-identical on both binaries. |
.mode csv |
RFC 4180 output — byte-identical on both binaries. |
.mode markdown |
Paste-ready GitHub-flavored table. |
.mode insert |
Regenerates rows as SQL. stock quotes "table" /
brew emits unquoted tab. |
.mode line |
One wide record per screen. stock uses " = " /
brew uses ": ". |
.headers on/off |
Toggles the header row — identical on both binaries. |
.width N N… |
Fixes column widths in column mode. |
.separator SEP |
Changes the field separator for list-family modes. |
.output FILE / .once FILE |
Redirects all subsequent output, or just the next statement's. |
.read FILE |
Runs a saved .sql file's statements. |
--safe |
Disables everything that could touch anything but the one named db file. Blocked-command
message framed differently: stock line 0: ... /
brew argv[3]: .... |
-init /dev/null |
Ignores ~/.sqliterc for this one invocation. |
Schema
| Command | Purpose |
|---|---|
.tables |
Lists tables/views. stock 4 columns/row / brew 5 — same 14 names either way. |
.schema ?PATTERN? / --indent |
CREATE text, optionally reformatted for humans. |
.indexes ?TABLE? |
Lists indexes. Bare form: stock shows
sqlite_autoindex_* too / brew omits them. |
.fullschema |
Whole schema plus a trailing sqlite_stat1 dump. FTS5 shadow tables:
stock adds IF NOT EXISTS /
brew doesn't. |
SELECT sql FROM sqlite_schema WHERE type='view'/'trigger' |
Exact defining text for a view or trigger. |
PRAGMA table_info(t) / pragma_table_info('t') |
Normal columns only — no generated/hidden columns. |
PRAGMA table_xinfo(t) |
Complete superset, including generated/hidden columns. |
PRAGMA foreign_key_list(t) |
Declared FK constraints for one table. |
PRAGMA index_list(t) / index_info(idx) |
A table's indexes / one index's key columns. |
SELECT m.name,p.name,p.type,p."notnull",p.pk FROM sqlite_schema m JOIN
pragma_table_info(m.name) p WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%' |
Every column of every table, one query — the flagship join. |
Profiling
| Command | Purpose |
|---|---|
SELECT * FROM t LIMIT 5 / ORDER BY id DESC LIMIT 5 |
A quick head / tail sample. |
SELECT count(*) - count(col) FROM t |
NULL count for one column. |
SELECT count(DISTINCT col) FROM t |
Cardinality — low values are GROUP BY candidates. |
SELECT min(col), max(col) FROM t |
Range — works on TEXT dates too. |
SELECT typeof(col), count(*) FROM t GROUP BY 1 |
Storage-class histogram — catches a mixed-affinity column red-handed. |
.timer on |
Per-statement timing. stock silent no-op /
brew prints Run Time:. |
Analysis
| Command | Purpose |
|---|---|
EXPLAIN QUERY PLAN <query> |
SCAN = full walk; SEARCH = index-assisted subset;
SEARCH…USING COVERING INDEX = never touches the table at all. |
.eqp on |
Auto-prints the plan before every result — a standing habit. |
.stats on |
~20-line memory/step-count report. brew adds a
Temporary data spilled to disk: line stock never
shows. |
RANK() OVER (...) / NTILE(4) OVER (...) /
SUM(...) OVER (...) |
Window-function analysis — save it as .sql for reproducibility. |
Export-import
| Command | Purpose |
|---|---|
.mode csv + .once FILE |
CSV export — RFC 4180 quoting automatic. |
.mode json / json_group_array(json_object(...)) /
-json |
Three ways to get the identical JSON shape out. |
.dump ?TABLE? |
Full or single-table reconstruction SQL. Full dump: brew
prepends a WARNING comment stock never emits. |
.dump t | sqlite3 new.db |
Lifts one table into its own fresh database. |
.import --csv --skip N FILE TABLE |
Re-imports CSV — use --skip only when the target table already exists (it
stacks with auto-create's own header consumption otherwise, silently dropping a data row). |
.backup ?DB? FILE / .clone NEWDB |
Binary, byte-identical copy — categorically different from .dump's logical
text. |
Safety
| Command | Purpose |
|---|---|
PRAGMA busy_timeout = ms; |
Retries a locked write for up to ms instead of failing instantly. |
database is locked error |
Another connection holds a conflicting lock. stock
Error: stepping, database is locked (5) /
brew Error in Nth command line argument: database is
locked. |
PRAGMA journal_mode = WAL / = DELETE |
WAL persists across reopen. The DELETE round trip isn't equally clean:
stock leaves 13 freelist pages + a stray -shm /
brew returns exactly to the original size. |
PRAGMA wal_checkpoint(MODE) |
Forces a checkpoint. PASSIVE never blocks; FULL/RESTART
invoke the busy-handler until clear. |
.backup FILE vs. plain cp |
.backup reads through the WAL correctly; a live cp can silently
miss a just-committed row. |
Maintenance
| Command | Purpose |
|---|---|
PRAGMA integrity_check; / quick_check; |
Full O(N log N) scan vs. a cheaper O(N) pass — neither catches FOREIGN KEY violations. |
PRAGMA foreign_key_check; |
The FK-violation check integrity_check skips. |
PRAGMA page_size; page_count; freelist_count; |
Real byte size and reclaimable-space math. |
VACUUM; |
Rebuilds + defragments — needs up to 2× free disk space. |
VACUUM INTO 'file'; |
Non-destructive compacted snapshot — source file untouched. |
ANALYZE; / SELECT * FROM sqlite_stat1 |
Gathers / inspects the planner's statistics. |
PRAGMA optimize; |
The modern replacement for hand-run ANALYZE — run at connection close. |
.recover |
Page-scan salvage of a corrupted database. Must be patched (strip the injected error, add a
trailing COMMIT;) before it can be trusted. Post-patch integrity_check:
stock's rescued db reports ok;
brew's halts outright on an invalid FTS5 file format error (nonzero
exit), never reaching ok. |
Every row above restates a command already sourced in its linked section — no new source citations on this page.
15 · Glossary
Every term this guide leans on, one line each, consistent with how it's actually used above. Link
directly to any row with #g-<term>.
| Term | Definition |
|---|---|
| Page | The database file's fixed-size unit of storage
(workshop.db uses 4096-byte pages); pages are numbered from 1, and page 1 doubles as
the header and the sqlite_schema root. (§1) |
| B-tree | The on-disk structure behind every table and every index; each
gets its own b-tree, rooted at the page number stored in sqlite_schema.rootpage. (§1) |
| sqlite_schema | The one real table describing every other table,
index, trigger, and view in the database; .tables/.schema/.indexes
are just formatted queries against it. (§1, §5) |
| rootpage | The sqlite_schema column holding a b-tree's
root page number; 0/NULL for views, triggers, and virtual tables, which have no b-tree of their
own. (§1, §5) |
| rowid | The hidden integer key an ordinary SQLite table indexes rows by
— the physical lookup key unless the table is declared WITHOUT ROWID. (§5) |
| WITHOUT ROWID | A table clustered directly on its declared
PRIMARY KEY in one b-tree instead of a separate rowid + PK-index pair; issue_tags is
workshop.db's example. (§2, §5) |
| Affinity | A column's recommendation (TEXT/NUMERIC/INTEGER/REAL/BLOB)
for how to coerce an inserted value — not an enforced type; issues.priority (declared
INTEGER) still stores the literal string 'high' as TEXT. (§6) |
| STRICT | A CREATE TABLE ... STRICT table that enforces
rigid, container-based typing instead of ordinary manifest typing; a STRICT INTEGER column rejects
a text insert outright. (§5) |
| PRAGMA | A SQLite-specific statement form (PRAGMA name; or
PRAGMA name = value;) for querying or setting connection/database configuration and
introspection data. (§5, §9, §10) |
| Table-valued pragma | A side-effect-free pragma callable
as pragma_name('arg') instead of PRAGMA name(arg) — filterable,
aggregable, and joinable, unlike the plain statement form. (§5) |
| Dot-command | A shell-only meta-command (.tables,
.mode, .backup, …) that must start at the left margin, live on one line,
and never appear mid-SQL-statement. (§4) |
| WAL (write-ahead log) | The journal mode where committed changes are
appended as frames to a -wal sidecar instead of touching the main file at commit time;
persists across reopen once set. (§9) |
| Checkpoint | The operation that folds committed WAL frames back
into the main database file; PASSIVE (the automatic default) never blocks,
FULL/RESTART try harder and can invoke the busy-handler. (§9) |
| -shm | The shared-memory sidecar holding the wal-index — an index that
helps readers locate pages in the -wal file quickly, not the log itself. (§9) |
| Journal | The rollback-journal sidecar (-journal) used by
non-WAL journal modes, holding changed pages' old content so a crash can be rolled back; a "hot"
journal must never be deleted by hand. (§9, §12) |
| Busy timeout | The PRAGMA busy_timeout = ms; setting
that installs a busy handler retrying a locked operation for up to ms milliseconds
instead of failing immediately with SQLITE_BUSY. (§9) |
| Integrity check | PRAGMA integrity_check, a
low-level scan for structural damage (bad offsets, freelist problems, missing pages); returns the
single row ok on a healthy file. (§10) |
| VACUUM | Rebuilds the entire database file, reclaiming freelist space and defragmenting; needs up to twice the file's size in free disk space to run. (§10) |
| Freelist | The set of pages a database file has marked reusable after
a DELETE but not yet reclaimed; PRAGMA freelist_count reports how many, VACUUM
is what actually reclaims them. (§10) |
| ANALYZE / sqlite_stat1 | The statement that gathers table/index
statistics the query planner uses to pick plans, stored in sqlite_stat1; PRAGMA
optimize is the modern, recommended way to invoke it. (§10) |
| EXPLAIN QUERY PLAN | Prefixed on any statement, shows the plan SQLite
intends to run without running it; SCAN = full walk, SEARCH =
index-assisted subset. (§7) |
| Covering index | An index that alone holds every column a
query needs, so SQLite never touches the underlying table b-tree; shown in EQP output as
SEARCH ... USING COVERING INDEX. (§7) |
| FTS5 | SQLite's full-text search virtual-table module; still reports
type='table' in sqlite_schema — there's no separate virtual-table type
value. (§5) |
| Shadow table | One of the real backing tables (_data,
_idx, _content, _docsize, _config) an FTS5
virtual table creates automatically; workshop.db's issue_search has all
5. (§5) |
| Generated column | A column computed from an expression over
other same-row columns instead of stored directly; comments.body_len (VIRTUAL) is
invisible to table_info but shown by table_xinfo. (§5) |
| .dump | Converts the database (or one named table) into plain SQL text —
CREATE plus INSERT statements that reconstruct it when piped back into
sqlite3; logical, not byte-identical across binaries. (§3, §8) |
| .backup | Writes a binary, byte-identical copy of the database file (an
alias for .save) — safe against a live database, unlike a plain cp. (§3, §9) |
| URI filename | A file:-prefixed connection string
(file:db.db?mode=ro) whose mode parameter selects
ro/rw/rwc/memory; ?immutable=1
skips locking entirely for a genuinely static file. (§9) |
Every definition above restates usage already sourced in its linked section — no new source citations on this page.
16 · Quiz & Sources
Twenty questions, every answer traceable to a specific section above. Reveal each answer only after you've committed to one.
"SQLite format 3\000"
— always exactly 16 bytes. It's how file (or any tool) recognizes a real SQLite
database independent of its name or extension. (§1)workshop.db,
mystery.db (a .backup copy), and not-a-db.db (plain text
wearing a .db name). What single command told them apart, before sqlite3
ever opened any of them?file workshop.db mystery.db not-a-db.db — the first two
reported SQLite 3.x database with matching database pages 156;
not-a-db.db reported plain ASCII text, caught by the magic header alone.
(§3, Lab 1).db file, name
two habits from this guide's opening ritual you should do before running a single
SELECT.ls -l plus a glob
for -wal/-shm/-journal), and run .dbinfo for a
21-line status read — both before ever risking a write, which is why the whole ritual opens
-readonly. (§2)sqlite3
binaries by version and path. What is each, and what does bare sqlite3 actually
resolve to on this machine?/usr/bin/sqlite3; Homebrew 3.53.3 at
$(brew --prefix sqlite)/bin/sqlite3. Bare sqlite3 resolves to neither — it's
MacPorts 3.35.5, the oldest and least-featured of the three. (§3)sqlite3 binaries, even when a naive FTS5 probe suggests no
difference at all?PRAGMA compile_options — stock reports 70 options (including
OMIT_LOAD_EXTENSION), brew 62 (including ENABLE_STAT4). A bare
SELECT fts5('x') is a false lead: it returns a blank line, exit 0, on both binaries
regardless of whether FTS5 is actually built in. (§3)sqlite3 invocation. What's the reliable way, and what fails if you
cram both into one positional argument instead?-cmd ".mode X" db "SQL;" — two separate arguments. A
dot-command and SQL crammed into one positional argument (even with an embedded newline)
fails on both binaries, just with different error text per binary. (§4).tables actually get its
list from — is there a real, standalone table underneath every schema-showing dot-command?PRAGMA
table_info and table_xinfo, and which real workshop.db column
demonstrates it?table_info omits generated and hidden columns;
table_xinfo is the complete superset, adding a hidden column.
comments.body_len — GENERATED ALWAYS AS (length(body)) VIRTUAL — carries
hidden=2 and is invisible to table_info. (§5)) WITHOUT
ROWID;. What does that tell you physically, and which real workshop.db table has
it?issues.priority in
workshop.db?SELECT typeof(col), count(*) FROM t GROUP BY 1 —
issues.priority (declared INTEGER, no STRICT/CHECK) came back
integer|341, null|113, text|46; the 46 text
rows are the literal string 'high'. (§6)QUERY PLAN
|--SCAN events
`--USE TEMP B-TREE FOR GROUP BYSCAN events means a full walk of all 5000 events
rows — there's no index on the filtered column (issue_id) — plus a temp b-tree to
satisfy the GROUP BY. Creating idx_events_issue turns the first line into
SEARCH events USING INDEX idx_events_issue (issue_id=?), visiting only the matching rows;
the temp b-tree stays either way since nothing indexes kind. (§7, §10 Lab 7).timer on sometimes print
nothing at all, and how do you know a query still actually ran?sqlite3 as JSON..mode json; json_group_array(json_object(...))
as a single SQL value; and the -json shell flag — all three verified to produce the
identical shape on both binaries. (§8).backup/.clone and .dump, and why does it matter across
sqlite3 versions specifically?.backup/.clone are binary — byte-identical
copies of the file regardless of which build wrote it. .dump is plain SQL text: logical,
but NOT byte-identical across binaries — real examples include brew's leading WARNING comment,
FTS5-blob differences, and a brew-only sqlite_stat4 table. (§3 Lab 0,
§8)cp dangerous, and what did this guide's real demo show?cp of just the main file can miss rows still only in
the -wal sidecar. Real capture: with one committed, not-yet-checkpointed
INSERT, cp's copy returned count(*) = 0 for that row;
.backup against the same live source correctly returned 1. Use
.backup, VACUUM INTO, or sqlite3_rsync instead. (§9)database is
locked. What's the one-line fix, and what did the real demo show it does to timing?PRAGMA
integrity_check and quick_check, and what does neither of them catch?integrity_check is the full O(N log N) scan (out-of-sequence
entries, freelist problems, missing pages, and more); quick_check skips the
UNIQUE-constraint and index-vs-table cross-checks for a cheaper O(N) pass. Neither catches FOREIGN
KEY violations — that's PRAGMA foreign_key_check's job. (§10).recover | sqlite3 rescued.db
on a corrupted copy and got a completely empty database. Why, and what has to happen before
.recover's output can actually be trusted?.recover hit the corruption mid-statement and printed an
inline diagnostic (sql error: (null) (25)) with no final COMMIT;, so the
receiving sqlite3 process rolled back the entire transaction on exit. You must capture
the output to a file, manually strip the injected error fragment, add a trailing COMMIT;,
and only then load it — and even patched, recovery is lossy: issues came back 500/500,
users only 16/40. (§10 Lab 6)-wal, -shm, or
-journal files are sitting next to a .db after a crash. What should you
not do, and what's the right fix?sqlite3; a clean open
replays and clears the hot journal/WAL automatically. (§12, §9)Sources
Primary: Command Line Shell For SQLite · PRAGMA Statements · The Schema Table · Datatypes In SQLite · SQLite FTS5 Extension · Query Result Formatting In The CLI · SQLite Release 3.33.0 changelog · SQLite forum: .sqliterc location · SQLite forum: ignoring .sqliterc · Generated Columns · Clustered Indexes and the WITHOUT ROWID Optimization · Database File Format · Write-Ahead Logging · File Locking And Concurrency In SQLite Version 3 · How To Corrupt An SQLite Database File · VACUUM · ANALYZE · EXPLAIN QUERY PLAN · Uniform Resource Identifiers · Result and Error Codes
Helper tools: sqlite-utils: CLI reference · litecli · litecli: Features · litecli: Config File · VisiData: Installation · VisiData: Loading Data · Datasette: Getting Started · Datasette: Installation · DuckDB: SQLite Extension · DuckDB: Installation
Local: sqlite3 3.43.2 (macOS stock) & 3.53.3 (Homebrew) ground truth, 2026-07-17.
All sources fetched 2026-07-17.