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.

Verified on sqlite3 3.43.2 (macOS stock) and 3.53.3 (Homebrew) · 2026-07-17 Homebrew sqlite 3.53.4 now available · not yet re-verified (stock 3.43.2 still current)

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]

One .db file: a header, a page grid, one b-tree per table/index workshop.db Header bytes 0–99 "SQLite format 3\000" page 1 (same page) sqlite_schema root b-tree type · name · tbl_name rootpage · sql page 2 issues table b-tree pages 3–4 page 5 page 6 page 7 page 8 page 9 page 10 idx_issues_project index b-tree pages 11–12 page 13 rootpage rootpage …and more pages (freelist, overflow, pointer-map) not shown here header (bytes 0–99) sqlite_schema root (page 1) a table b-tree an index b-tree an ordinary page
Simplified model — every table and index really is its own b-tree rooted somewhere in the page grid, but this leaves out cell headers, overflow-page chaining, and varint-encoded record layout inside each page; those are out of scope for this guide. [fileformat2.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.

  1. Identify it before you trust the extension. file reads 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]

  2. Check size and sidecars. ls -l the 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/null

    The file is exactly 638,976 bytes — 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 plain journal_mode = delete by 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]

  3. Open it so you can't break it. -readonly makes 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 as Error 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 the file:workshop.db?mode=ro URI form.

    [local sqlite3 3.43.2+3.53.3, captured 2026-07-17]

  4. .dbinfo — the fastest way to "read" a file without a schema dump. Stock and brew agree on 20 of its 21 lines for workshop.db:

    FieldValueWhy it matters
    database page size4096the header field from §1, in bytes
    write/read format11 = legacy rollback-journal format, 2 = WAL format
    reserved bytes12space reserved per page for extensions (e.g. encryption)
    database page count156× page size = the file's real byte size
    freelist page count0unused pages waiting to be reclaimed — none yet
    schema cookie18bumps on every schema change; a cheap "has the schema changed" check
    schema format4which historical schema-table dialect wrote this file
    text encodingutf8not UTF-16le/UTF-16be — matters for byte-level tooling
    number of tables / indexes / triggers / views14 / 5 / 1 / 1your first size-up before running a single SELECT
    schema size2565bytes of schema text stored in sqlite_schema.sql

    The 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 .dbinfo lines (including any app-defined user version / application id tags) fall outside this guide's captured ground truth, since this particular build script never sets them; don't expect every line .dbinfo prints to be covered here.

    [local sqlite3 3.43.2+3.53.3, captured 2026-07-17]

  5. List what's actually in it. .tables queries sqlite_schema for 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  issues

    stock 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]

  6. Get a row count for every table in one loop. No dot-command does this directly, but sqlite_schema plus 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 1

    Byte-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]

  7. Skim the schema, human-formatted. .schema --indent reformats CREATE statements for readability without changing what they say — it only removes column-alignment whitespace and tightens CHECK (...) to CHECK(...), 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.

BinaryPathVersionNotes
Stock (Apple)/usr/bin/sqlite33.43.2 (2023-10-10)ships with macOS
MacPorts/opt/local/bin/sqlite33.35.5 (2021-04-19)what bare sqlite3 resolves to here
Homebrew$(brew --prefix sqlite)/bin/sqlite33.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.

Lab 0 — Build workshop.db

Goal: build the shared example database this whole guide uses, on both binaries, and prove the build is deterministic.

  1. 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;
    
  2. Run it on the stock binary:

    $ rm -f workshop.db && /usr/bin/sqlite3 workshop.db < build-workshop.sql && echo STOCK-OK
    STOCK-OK

    No 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.

  3. 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]

Lab 1 — Mystery-file triage

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.

  1. Create the two files this lab triages: a faithful copy of workshop.db made 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

    .backup is 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]

  2. Run file on 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 text

    The first divergence is immediate, before sqlite3 ever opens anything: two real SQLite databases, and one plain-text impostor wearing a .db name.

  3. Confirm the two real files are structurally the same database, not just the same magic bytes. file's own database pages 156 already matches workshop.db's §2 .dbinfo capture exactly; only the header's housekeeping counters (file change counter, schema cookie, version-valid-for) differ, because .backup writes 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

Interactivesqlite3 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;"
ModeCaptured outputReach 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.

[SQLite forum; SQLite forum]

.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]

Getting SQL in, getting results out stdin (piped) echo 'SQL' | sqlite3 db non-interactive, exits at EOF positional args / -cmd sqlite3 db "SQL;" one-shot, order preserved .read FILE .read script.sql runs a saved .sql file shell core executes dot-commands + SQL .mode selects the rendering -cmd ".mode X" terminal default destination column / box / qbox .once / .output → file next statement / until reset csv, json, insert, … pipe to another command .output |CMD .excel = .once -x Gotcha A dot-command + SQL crammed into ONE positional argument fails on both binaries — combine them with -cmd ".mode X" db "SQL;" instead. input source shell core / .mode output destination known pitfall
Simplified flow — every input path ends up running the same dot-command/SQL grammar through the same core; .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

[cli.html; climode.html]

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:

QueryColumns 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:

FromReferenceson_update / on_delete / match
assignee_idusers.idNO ACTION / NO ACTION / NONE
author_idusers.idNO ACTION / NO ACTION / NONE
project_idprojects.idNO 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:

QueryReal 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" tablesissue_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_*

NeedDot-commandPRAGMA statementpragma_* function
Column listPRAGMA table_info(t)pragma_table_info('t')
Foreign keysPRAGMA 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?, .fullschemaSELECT sql FROM sqlite_schema
Table/view names.tables ?PATTERN?SELECT name FROM sqlite_schema
Interactive-only, fastest to typeyes — that's the whole pointnono
Usable from a script/host-language driverno (dot-commands are shell-only)yes, as a statementyes, as a query
Filterable / joinable with other datanono — one fixed result shapeyes — it's an ordinary table-valued FROM source

[pragma.html; cli.html]

Lab 2 — Blind schema map

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.

  1. If you followed Lab 1's cleanup, mystery.db no longer exists — recreate it the same way Lab 1 did, a faithful binary copy of workshop.db:

    $ sqlite3 workshop.db ".backup mystery.db"

    Because .backup is a binary copy (not a re-run of the build script), mystery.db carries the exact same tables, rows, and row counts as workshop.db — every number below applies to it unchanged.

  2. List every table and get a row count for each, the same sqlite_schema + shell-loop recipe from §2, pointed at mystery.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 1

    7 "real" tables plus a 5-table FTS5 shadow cluster (the issue_search_* names) — 13 rows of table+count in total, plus the issue_search virtual table itself makes 8 top-level objects.

  3. Map primary and foreign keys with PRAGMA foreign_key_list, one table at a time (run it against mystery.db for issues, projects, comments, issue_tags, and events in 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_idusers.id, project_idprojects.id). events is the interesting negative result: its foreign_key_list comes back empty, even though issue_id clearly relates to issues — a relationship that exists in practice but was never declared with REFERENCES.

  4. Find the append-heaviest table — no dot-command does this directly, but step 2's counts already answer it: events at 5000 rows is 2.5× the next-largest real table (comments, 2000).

  5. 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 ROWID line and the FTS5 CREATE VIRTUAL TABLE line are exact ground-truth captures reused from §2 and this section's FTS5 discussion above; the view/trigger names and body_len|2 are 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
FindAnswer
Tables7 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-heaviestevents — 5000 rows, the build script's own comment literally calls it "biggest table"
Viewopen_issues — non-closed issues joined to project + author
Triggertrg_issue_close — fires AFTER UPDATE OF status, inserts a closed event
Generated columncomments.body_lenGENERATED ALWAYS AS (length(body)) VIRTUAL, hidden=2, invisible to table_info
WITHOUT ROWIDissue_tags — composite PK (issue_id, tag_id), clustered directly on it
Virtual tableissue_search — FTS5 on (title, body), 5 shadow tables (_data/_idx/_content/_docsize/_config)
Undeclared relationshipevents.issue_id — no REFERENCES clause; foreign_key_list(events) is empty despite the real relationship
mystery.db, mapped blind — 7 tables + FTS5 users 40 rows PK id projects 8 rows PK id · FK owner_id issues 500 rows · PK id FK project_id, author_id, assignee_id priority: mixed affinity (INTEGER col, holds TEXT 'high') assignee_id: ~58% NULL comments 2000 rows · PK id FK issue_id, author_id body_len: GENERATED VIRTUAL (hidden=2) issue_tags 826 rows PK(issue_id,tag_id) WITHOUT ROWID (composite PK, clustered) tags 12 rows PK id events 5000 rows · PK id issue_id (no FK declared) append-heaviest table no index on issue_id issue_search (virtual) FTS5 on (title, body) 500 rows · no declared FK +5 shadow tables: _data _idx _content _docsize _config populated from issues+comments by script owner_id project_id author_id / assignee_id → users comments.author_id comments.issue_id tag_id events.issue_id (no REFERENCES) declared FK (REFERENCES) logical only, not enforced quirk text = planted quirk, this guide's running motif
Structure only — full column lists and every FK are in the table above and §3's Lab 0 script; this diagram highlights relationships and the six planted quirks, not a complete schema dump. [local sqlite3 3.43.2+3.53.3, captured 2026-07-17]

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:

RecipeColumnReal 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.

Lab 3 — Profile workshop.db

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.

  1. Refresh the row counts with the flagship loop (identical recipe, run directly against workshop.db this time rather than mystery.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\"")"; done

    13 rows back — 7 base tables plus the FTS5 internals, the same shape as §6's own 13-table listing — with events still the largest of the 7 base tables, by a wide margin over the next-biggest (comments).

  2. 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.

  3. Run the typeof() histogram against issues.priority — declared INTEGER, no STRICT, no CHECK:

    $ 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
FindAnswer
Busiest tableevents — 5000 rows, 2.5× the next-largest (comments, 2000)
NULL-heavy columnissues.assignee_id — 292 of 500 rows NULL (round(100.0*sum(assignee_id IS NULL)/count(*))58.0)
Mixed-affinity columnissues.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]

Lab 4 — Four questions, four queries

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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.txt

    Identical output all three times — once printed to the terminal directly, once redirected into busiest.txt by .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
QuestionAnswer
Q1 — biggest month + running total2024-06, 25 closed that month, running total 88 through end of June
Q2 — tie handling3-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 8project 8 is slower: 52.5 days vs. project 1's 39.0 days, a gap of about 13.5 days
Q4 — busiest issueissue 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]

FormReach for this when…
.mode jsonan 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 flaga 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
Formatbinary — an exact copy of the database file plain SQL text — CREATE/INSERT statements
Cross-version identitybinary-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 / diffableno — opaque binary yes — plain text, line-diffable in any version control system
Portable to other enginesno — SQLite's own file format only yes — the docs' own example pipes a dump into psql
Safe against a live, open databaseyes, 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]

Lab 5 — Export, summarize, round-trip

Goal: get data out of workshop.db three different ways, and prove one of them round-trips cleanly back in.

  1. Export every issue's id/title/status to CSV — the recipe from above, without the LIMIT:

    $ sqlite3 -cmd ".headers on" -cmd ".mode csv" -cmd ".once issues.csv" workshop.db \
        "SELECT id, title, status FROM issues;"
    $ wc -l issues.csv

    501 — one header line plus all 500 issues. This file opens directly in any spreadsheet application (double-click, or .once -x / .excel to skip the intermediate file and open one automatically); nothing further to verify by hand once you see the row count line up.

  2. Export a JSON summary — status counts, not raw rows — the kind of small, self-describing payload worth handing to jq or 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.tool

    A 3-element JSON array, one object per status — the identical closed/open/in_progress counts already established in §6, now in a shape jq '.[] | select(.status=="open")' could filter directly.

  3. 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;"
    40

    40 — a complete, independent database containing nothing but the users table, built from text alone.

  4. 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;"
    500

    500 — matching issues' 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=WALwal_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 mode: writer appends, readers consult the index, checkpoint folds it back Writer one at a time BEGIN IMMEDIATE ...COMMIT -wal (write-ahead log) append-only frame log frame 1 frame 2 frame 3 ... COMMIT = append a frame here -shm (wal-index) shared-memory index into -wal workshop.db (main file) pages unchanged... ...until a checkpoint runs Readers many, concurrent each sees one consistent snapshot: recent frames from -wal (via -shm) plus whatever main-file pages it hasn't superseded yet no lock needed to read append frame checkpoint (PASSIVE default) Hazard Copying workshop.db alone (no -wal / -shm) while a writer is open can lose committed rows — .backup reads through the WAL correctly; a plain cp does not. writer -wal frames main file readers checkpoint op known hazard
Simplified flow — a writer only ever appends to -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]

Lab 6 — Break it, then see how much comes back

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.

  1. Make a disposable copy and break it — 32 bytes of 0xdeadbeef, written 40 bytes into page 2 (offset 4096 + 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
  2. 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.)

  3. Try the obvious salvage recipe first — .recover scans 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 empty rescued.db — zero tables at all, so that final SELECT has nothing to query. This is the real footgun, not a hypothetical one.

  4. Find out why: run .recover unpiped and look at its raw output. Partway through reconstructing issues, 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 final COMMIT; after that glitch, so the receiving sqlite3 rescued.db process rolls back its entire transaction on exit — every CREATE TABLE/INSERT that 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 .dump runs to full completion — every table's SQL text present, including a clean-looking row for the very issue that broke .recover — but ends with ROLLBACK; -- due to errors instead of COMMIT;, because it detected the corruption internally and refused to commit. Piping it straight into a new db hits the identical empty-database result.

  5. Salvage it properly: capture .recover's output to a file, manually strip the one injected sql error: (null) (25) fragment so the split statement rejoins correctly, add a trailing COMMIT;, 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 .recover choking mid-generation on exactly this table. users: 16 / 40 — recovered less than half, even though the users table'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.

  6. 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 reaching ok at 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 .recover script 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]

Lab 7 — Index it, time it, put it back

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.

  1. Turn the timer on, then run §7's events-by-issue query as-is — no index exists on events.issue_id yet:

    $ 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 BY

    The plan text — a full SCAN of all 5000 events rows, plus a temp b-tree for the GROUP BY — is byte-identical on both binaries, already confirmed in §7. .timer on is not: stock stays silent, never printing a Run 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 own Run Time: line here as the real "before" number, not a value quoted from this page.

  2. 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 BY

    SCAN becomes SEARCH ... USING INDEX idx_events_issue (issue_id=?) — again byte-identical text on both binaries. If you're on brew, compare this run's Run 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.

  3. Stretch: before you drop the index, rerun the query with a different literal — issue_id = 100 instead of 42 — and confirm the plan text is identical. The plan itself is reported as the parameterized issue_id=?, not a specific value, so any concrete literal takes the same SEARCH path; that's a property of the plan, not something this guide had to re-verify per value.

  4. Put the workshop back exactly how every other section of this guide expects to find it — events.issue_id unindexed:

    $ sqlite3 workshop.db "DROP INDEX idx_events_issue;"
    $ sqlite3 workshop.db ".indexes events"

    No output — .indexes events confirms 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 SCANSEARCH 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-utilspip 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]

liteclipip 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]

VisiDatapip3 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]

Datasettepip 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]

DuckDBbrew 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 dependenciesplain sqlite3 — the floor this whole guide is built on
Auto-type and load a CSV/JSON file as a new tablesqlite-utils insert
Pipe query results straight into jq/JSON toolingsqlite-utils query
Explore an unfamiliar schema interactively, with autocompletelitecli
Scroll/sort/filter a big table like a spreadsheetVisiData
Share a browsable UI + JSON API for a databaseDatasette
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.

SymptomLikely diagnosisFix / 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.

Lab 8 — Audit checklist

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.

  1. 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)

  2. Confirm which binary you're about to run everything through. Bare sqlite3 is not a safe assumption on every machine:

    $ command -v sqlite3; sqlite3 --version

    (§3)

  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)

  4. .dbinfo plus 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_count is the real on-disk size; page_size × freelist_count is how much of that is already reclaimable by VACUUM. (§2, §10)

  5. 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_id was:

    $ 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)

  6. 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)

  7. integrity_check — on a copy, never the live file.

    Never run integrity_check, or anything else, against a naive cp of 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 .backup copy 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)

  8. Review indexes against your hottest query with EXPLAIN QUERY PLAN. Run the query you actually care about, prefixed with EXPLAIN QUERY PLAN — look for an unexpected SCAN where you expected a SEARCH:

    $ sqlite3 -readonly "$DB" "EXPLAIN QUERY PLAN <your hottest query here>;"

    (§7)

  9. Freshness check — has ANALYZE ever actually run? An empty sqlite_stat1 means 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 optimize has never run against this database; any rows back confirm the shape (tbl|idx|stat). (§10)

  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

CommandPurpose
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

CommandPurpose
-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

CommandPurpose
.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

CommandPurpose
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

CommandPurpose
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

CommandPurpose
.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

CommandPurpose
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

CommandPurpose
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>.

TermDefinition
PageThe 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-treeThe 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_schemaThe 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)
rootpageThe 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)
rowidThe hidden integer key an ordinary SQLite table indexes rows by — the physical lookup key unless the table is declared WITHOUT ROWID. (§5)
WITHOUT ROWIDA 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)
AffinityA 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)
STRICTA 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)
PRAGMAA 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 pragmaA 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-commandA 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)
CheckpointThe 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)
-shmThe shared-memory sidecar holding the wal-index — an index that helps readers locate pages in the -wal file quickly, not the log itself. (§9)
JournalThe 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 timeoutThe 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 checkPRAGMA 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)
VACUUMRebuilds the entire database file, reclaiming freelist space and defragmenting; needs up to twice the file's size in free disk space to run. (§10)
FreelistThe 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_stat1The 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 PLANPrefixed on any statement, shows the plan SQLite intends to run without running it; SCAN = full walk, SEARCH = index-assisted subset. (§7)
Covering indexAn 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)
FTS5SQLite's full-text search virtual-table module; still reports type='table' in sqlite_schema — there's no separate virtual-table type value. (§5)
Shadow tableOne 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 columnA 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)
.dumpConverts 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)
.backupWrites 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 filenameA 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.

1. What are the first 16 bytes of every SQLite database file, and why do they matter to a shell or tool inspecting it?
2. Lab 1 built three files: 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?
3. Handed an unfamiliar .db file, name two habits from this guide's opening ritual you should do before running a single SELECT.
4. This guide compares two sqlite3 binaries by version and path. What is each, and what does bare sqlite3 actually resolve to on this machine?
5. Which single command reveals real feature-set differences between two sqlite3 binaries, even when a naive FTS5 probe suggests no difference at all?
6. You want one dot-command and one SQL statement in a single one-shot sqlite3 invocation. What's the reliable way, and what fails if you cram both into one positional argument instead?
7. Where does .tables actually get its list from — is there a real, standalone table underneath every schema-showing dot-command?
8. What's the difference between PRAGMA table_info and table_xinfo, and which real workshop.db column demonstrates it?
9. A table's schema ends in ) WITHOUT ROWID;. What does that tell you physically, and which real workshop.db table has it?
10. Which single technique catches a column secretly holding more than one storage class, and what did it reveal about issues.priority in workshop.db?
11. Read this real captured plan and say what's inefficient about it:
QUERY PLAN
|--SCAN events
`--USE TEMP B-TREE FOR GROUP BY
12. Why does .timer on sometimes print nothing at all, and how do you know a query still actually ran?
13. Name three distinct ways to get a query result out of sqlite3 as JSON.
14. What's the categorical difference between .backup/.clone and .dump, and why does it matter across sqlite3 versions specifically?
15. Name the five states of SQLite's locking state machine, in order, and say which one means "no new readers get in."
16. Why is copying a live WAL-mode database with a plain cp dangerous, and what did this guide's real demo show?
17. A write fails instantly with database is locked. What's the one-line fix, and what did the real demo show it does to timing?
18. What's the difference between PRAGMA integrity_check and quick_check, and what does neither of them catch?
19. Lab 6 ran .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?
20. Stray -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?

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.