Getting Started
CLI Basics
SQLite stores an entire database in a single file. The sqlite3 CLI opens or creates a .sqlite/.db file on demand. Unlike MySQL/PostgreSQL, there's no server process — applications link the SQLite library directly. .dump exports the full database as text SQL for backup or migration.
# open/create a database file
sqlite3 mydb.sqlite
# at the sqlite> prompt
.help # show help
.tables # list tables
.schema users # show CREATE statement for users
.databases # list attached databases
.dump # dump entire DB as SQL text
.quit # exit
# execute SQL from a file
sqlite3 mydb.sqlite < script.sql
# execute a query from command line
sqlite3 mydb.sqlite "SELECT COUNT(*) FROM users;"Dot Commands
Dot commands are interpreted by the sqlite3 CLI, not the SQL engine — they never need a semicolon and aren't available through programming APIs. .mode controls output formatting (try 'column' with .header on for readable interactive use). .schema shows CREATE statements; .fullschema adds indexes, views, and triggers. Use .help to discover more.
-- dot commands are CLI-only, NOT SQL (no semicolon needed)
.help -- list all dot commands
.tables -- list all tables
.schema -- schema for all tables
.schema users -- schema for the users table
.fullschema -- schema incl. indexes, triggers, views
.indices users -- list indexes on users
.header on -- show column headers
.mode column -- column-aligned output
.mode list -- default, pipe-separated
.mode csv -- CSV output
.mode json -- JSON output
.show -- show current settings
.width 10 20 30 -- set column widths
.timer on -- show query execution timeDatabase File Management
ATTACH DATABASE lets one connection query up to 10 database files simultaneously as schemas (main, temp, plus attached). The :memory: URI creates a pure in-memory database that vanishes on close — ideal for tests or scratch computation. URI filenames (file:...) enable read-only, shared-cache, and other modes. Each attached DB is a separate file on disk.
-- attach another database file to the current connection
ATTACH DATABASE 'archive.sqlite' AS archive;
DETACH DATABASE archive;
-- list attached databases (main + attached)
.databases
-- the in-memory database (never written to disk)
sqlite3 :memory:
-- temporary database (deleted when connection closes)
sqlite3 "" -- empty arg => temp DB
-- open read-only
sqlite3 "file:mydb.sqlite?mode=ro" --readonly
-- query the underlying page size / file format
PRAGMA page_size;
PRAGMA journal_mode;Headers & Output Formatting
Output formatting only affects the CLI, never the data itself. 'column' mode with .headers on is the most readable for humans. 'list' (default) is best for shell pipelines. 'json' and 'csv' make SQLite a handy converter. .output redirects results to a file; remember to switch back with .output stdout. .mode box/table draw ASCII borders.
-- readable interactive mode
.headers on
.mode column
.width 15 20 10
SELECT id, name, email FROM users LIMIT 5;
-- pipe-separated (good for scripts)
.mode list
.separator "|"
SELECT id, name FROM users;
-- boxed table output (sqlite 3.36+)
.mode box
.mode table
-- JSON output for piping to other tools
.mode json
SELECT id, name FROM users;
-- write output to a file
.output users.txt
SELECT id, name FROM users;
.output stdoutExecuting Scripts & SQL Files
.read executes a file of SQL inside an open CLI session; redirecting with < runs it before the interactive prompt. --bail stops the batch on the first error (otherwise SQLite keeps going, which can mask failures). .parameter (3.44+) binds named parameters from the shell, avoiding string interpolation. Always sanity-check scripts with .echo on in development.
-- run a .sql file from the shell
sqlite3 mydb.sqlite ".read setup.sql"
-- or piped in
sqlite3 mydb.sqlite < setup.sql
-- inside the CLI
.read setup.sql
-- stop on first error (shell flag)
sqlite3 mydb.sqlite --bail < script.sql
-- echo commands as they run
.echo on
.read migrations.sql
-- show EXPLAIN before each statement (debug)
.explain on
-- parameterize from the shell (sqlite 3.44+)
sqlite3 mydb.sqlite \
-cmd ".parameter set @min 18" \
"SELECT name FROM users WHERE age >= @min"Tables & Data Types
Creating Tables
INTEGER PRIMARY KEY is an alias for the rowid — it auto-increments and is the fastest possible key. AUTOINCREMENT changes the algorithm so deleted IDs are never reused (slightly slower; usually unnecessary). DEFAULT (expr) allows arbitrary expressions like datetime('now'). CREATE TABLE AS SELECT creates a table preloaded with query results but copies no constraints or indexes.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
age INTEGER CHECK (age >= 0),
role TEXT NOT NULL DEFAULT 'user',
created TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
total REAL NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE ON UPDATE CASCADE
);
-- quick table from a query
CREATE TABLE archive AS
SELECT * FROM orders WHERE created < '2024-01-01';Storage Classes & Type Affinity
SQLite uses dynamic typing: any column can hold any of the 5 storage classes. 'Type affinity' tries to convert values when possible (e.g., '42' in an INTEGER column becomes integer 42) but never errors on mismatch. STRICT tables (3.37+) restore traditional rigid type checking per column. typeof() reports the actual stored class. This flexibility is powerful but requires discipline — use STRICT for new schemas where data integrity matters.
-- SQLite has 5 storage classes (not strict types):
-- NULL, INTEGER, REAL, TEXT, BLOB
CREATE TYPE demo... -- not supported
-- columns have 'type affinity', not enforced types
CREATE TABLE t (
a INTEGER, -- affinity INTEGER
b TEXT, -- affinity TEXT
c REAL, -- affinity REAL
d BLOB, -- affinity BLOB (no conversion)
e -- no affinity (accepts anything)
);
-- this WORKS despite 'INTEGER' column (dynamic typing)
INSERT INTO t (a) VALUES ('hello');
SELECT typeof(a) FROM t; -- 'text'
-- STRICT tables (SQLite 3.37+) actually enforce types
CREATE TABLE strict_t (a INTEGER, b TEXT) STRICT;
INSERT INTO strict_t VALUES ('hi', 1); -- errorCommon Data Types
SQLite has no native DATE/TIME or BOOLEAN types — store timestamps as ISO-8601 TEXT ('YYYY-MM-DD HH:MM:SS') for sortability and use date/time functions to manipulate them. Booleans are integers 0/1. VARCHAR(n) length limits are ignored (compatibility only). NUMERIC affinity tries INTEGER first, then REAL, preserving exact decimals when possible. BLOB stores raw bytes verbatim.
-- integers
CREATE TABLE numerics (
small INTEGER, -- 1/2/4/8 bytes depending on value
big INTEGER, -- up to 64-bit
-- no separate BIGINT/SMALLINT, all map to INTEGER affinity
flag BOOLEAN -- stored as 0 (false) or 1 (true)
);
-- text & blobs
CREATE TABLE blobs (
name TEXT, -- variable-length UTF-8, no length limit
data BLOB, -- raw bytes, no conversion
note VARCHAR(255) -- length ignored, affinity TEXT
);
-- real numbers
CREATE TABLE floats (
price REAL, -- 8-byte IEEE float
precise NUMERIC(10,2) -- affinity NUMERIC, decimal preserved if exact
);
-- dates: SQLite has no native DATE type — store as TEXT (ISO-8601)
CREATE TABLE events (
ts TEXT -- 'YYYY-MM-DD HH:MM:SS' recommended
);Altering Tables
SQLite's ALTER TABLE is intentionally limited for a serverless engine. ADD COLUMN is O(1) (no table rewrite). RENAME COLUMN (3.25+) and DROP COLUMN (3.35+) are modern conveniences. For type changes or constraint additions, use the 12-step table-rebuild pattern (create new, copy, drop, rename, rebuild indexes). Foreign keys must be temporarily disabled during rebuilds — see PRAGMA legacy_alter_table.
-- add a column (fast, appends to end)
ALTER TABLE users ADD COLUMN bio TEXT;
-- rename a column (SQLite 3.25+)
ALTER TABLE users RENAME COLUMN name TO username;
-- rename a table
ALTER TABLE users RENAME TO accounts;
-- drop a column (SQLite 3.35+)
ALTER TABLE users DROP COLUMN deprecated_field;
-- SQLite CANNOT: change a column type, add constraints to
-- an existing column, or reorder columns. Workaround:
-- 1) create new table with desired schema
-- 2) INSERT INTO new SELECT * FROM old
-- 3) DROP old; ALTER TABLE new RENAME TO old
-- 4) recreate indexes/triggersConstraints
Constraints enforce data integrity at the engine level. PRIMARY KEY implies NOT NULL and UNIQUE. CHECK can reference any column in the row. ON CONFLICT clauses (OR IGNORE / OR REPLACE / OR ABORT / OR FAIL / OR ROLLBACK) control per-statement conflict resolution — OR REPLACE deletes the conflicting row then inserts, which resets its rowid. For UPSERT semantics, prefer ON CONFLICT ... DO UPDATE (see CRUD section).
CREATE TABLE products (
id INTEGER PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price REAL NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
category TEXT CHECK (category IN ('a','b','c')),
-- composite unique constraint
UNIQUE (name, category),
-- table-level check
CHECK (price * stock < 1000000)
);
-- name a constraint for clearer error messages
CREATE TABLE t (
x INTEGER CONSTRAINT x_positive CHECK (x > 0)
);
-- conflict handling
INSERT OR IGNORE INTO products (sku, name, price) VALUES (...);
INSERT OR REPLACE INTO products (...) VALUES (...);
UPDATE OR ABORT products SET stock = stock - 1 WHERE id = 1;CRUD Operations
INSERT
Multi-row INSERT is dramatically faster than per-row inserts in a loop — batch them. INSERT ... SELECT copies data between tables. last_insert_rowid() returns the rowid of the most recent INSERT on the current connection (not transaction). For apps, prefer parameterized inserts (see Integration section) to avoid SQL injection and quoting headaches. Use DEFAULT to explicitly take a column's default value.
-- single row
INSERT INTO users (username, email, age)
VALUES ('alice', '[email protected]', 30);
-- multiple rows in one statement (fast)
INSERT INTO users (username, email) VALUES
('bob', '[email protected]'),
('carol', '[email protected]'),
('dave', '[email protected]');
-- insert from a query
INSERT INTO archive_users
SELECT * FROM users WHERE active = 0;
-- explicit NULL / default
INSERT INTO users (username, email, age)
VALUES ('eve', '[email protected]', DEFAULT);
-- return the new rowid (handy in apps)
INSERT INTO users (username) VALUES ('frank');
SELECT last_insert_rowid(); -- returns the new idUPSERT (ON CONFLICT)
ON CONFLICT (UPSERT) is SQLite's idiom for 'insert or update if exists' — far safer than INSERT OR REPLACE, which deletes the existing row (firing DELETE triggers and resetting the rowid). 'excluded' refers to the row proposed for insertion. DO NOTHING silently skips. Conflict targets can be any UNIQUE or PRIMARY KEY constraint. This is the correct pattern for counters, last-seen updates, and idempotent imports.
-- insert, but update on conflict
INSERT INTO users (id, username, email)
VALUES (1, 'alice', '[email protected]')
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
username = excluded.username;
-- ignore on conflict
INSERT INTO users (id, username)
VALUES (1, 'alice')
ON CONFLICT(id) DO NOTHING;
-- conflict on a specific unique constraint
INSERT INTO users (username, email)
VALUES ('alice', '[email protected]')
ON CONFLICT(email) DO UPDATE SET
username = excluded.username;UPDATE
Always include WHERE unless you truly intend to update every row — SQLite has no dry-run mode. UPDATE ... FROM (3.33+) and UPDATE ... RETURNING (3.35+) bring SQLite close to PostgreSQL's ergonomics. RETURNING is invaluable for apps needing the post-update state without a separate SELECT. CASE enables single-statement bulk updates with per-row logic, which is much faster than row-by-row updates.
-- basic update (ALWAYS use WHERE in production!)
UPDATE users SET age = 31 WHERE id = 1;
-- multiple columns
UPDATE users
SET age = age + 1, role = 'admin'
WHERE username = 'alice';
-- conditional update with CASE
UPDATE products
SET price = CASE
WHEN category = 'a' THEN price * 0.9
WHEN category = 'b' THEN price * 0.95
ELSE price
END;
-- update from another table (subquery)
UPDATE orders
SET total = (SELECT SUM(qty * price)
FROM items WHERE order_id = orders.id);
-- UPDATE ... RETURNING (SQLite 3.35+)
UPDATE users SET role = 'admin'
WHERE id IN (1,2,3)
RETURNING id, username, role;DELETE
SQLite has no TRUNCATE — DELETE FROM table removes all rows but fires row-level triggers and does not reset the AUTOINCREMENT counter (stored in sqlite_sequence). DELETE ... RETURNING (3.35+) logs what was removed. For audit data, prefer soft deletes (a deleted_at column) over hard deletes. Free space from deletes is reused by future inserts; VACUUM reclaims it to the OS.
-- basic delete
DELETE FROM users WHERE id = 1;
-- delete with a subquery condition
DELETE FROM orders
WHERE user_id IN (SELECT id FROM users WHERE active = 0);
-- DELETE ... RETURNING
DELETE FROM sessions
WHERE expires < datetime('now')
RETURNING id, user_id;
-- delete ALL rows (fast, but fires triggers)
DELETE FROM logs;
-- TRUNCATE equivalent: delete all + reset rowid
DELETE FROM logs;
DELETE FROM sqlite_sequence WHERE name = 'logs';
-- soft delete pattern
UPDATE users SET deleted_at = datetime('now') WHERE id = 1;SELECT Basics
SELECT is the workhorse. LIMIT count OFFSET skip implements pagination (keyset pagination is faster for large offsets — see Querying section). Aliases (AS, optional) improve readability and are required for computed columns in views. DISTINCT collapses identical rows; consider GROUP BY for finer control. CASE adds if/else logic to result sets and is usable in any clause.
-- basic select
SELECT id, username, email FROM users;
-- limit and offset
SELECT id, username FROM users LIMIT 10 OFFSET 20;
SELECT id, username FROM users LIMIT 20, 10; -- offset, count
-- aliasing columns and tables
SELECT u.id AS user_id, u.username AS name
FROM users AS u;
-- expressions
SELECT
username,
age,
age * 365 AS days_alive,
UPPER(username) AS upper_name
FROM users;
-- distinct rows
SELECT DISTINCT category FROM products;
-- conditional output
SELECT name,
CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status
FROM users;Querying & Filtering
WHERE & Operators
NULL is special in SQL — it's 'unknown', not a value. = NULL always returns NULL (treated as false), so use IS NULL / IS NOT NULL. NULL combined with AND/OR follows three-valued logic. BETWEEN is inclusive. IN is shorthand for OR-equality; avoid IN (NULL, ...) as it behaves oddly. Use parentheses liberally to make compound conditions unambiguous.
-- comparison operators
SELECT * FROM products WHERE price < 50;
SELECT * FROM products WHERE price BETWEEN 10 AND 100;
SELECT * FROM users WHERE age IN (18, 21, 25);
SELECT * FROM users WHERE age NOT IN (18, 21);
-- NULL handling (NEVER use = NULL)
SELECT * FROM users WHERE email IS NULL;
SELECT * FROM users WHERE email IS NOT NULL;
-- logical operators
SELECT * FROM users
WHERE age >= 18 AND age <= 65 AND role = 'admin';
SELECT * FROM users
WHERE role = 'admin' OR role = 'editor';
-- range with multiple conditions
SELECT * FROM orders
WHERE (status = 'shipped' AND total > 100)
OR (status = 'pending' AND total > 500);ORDER BY & LIMIT
ORDER BY without a unique tiebreaker gives non-deterministic order across runs — always add a unique column (like id) as the final sort key. OFFSET pagination is O(n) because it scans and discards rows; keyset pagination (WHERE id > last_seen_id) is O(limit) and the right choice for large tables. RANDOM() requires a full sort — use it only on small result sets or sampled subsets.
-- ascending (default) / descending
SELECT * FROM users ORDER BY username;
SELECT * FROM users ORDER BY created DESC;
SELECT * FROM users ORDER BY age DESC, username ASC;
-- sort with NULLs first or last (SQLite 3.30+)
SELECT * FROM tasks ORDER BY due_date ASC NULLS FIRST;
SELECT * FROM tasks ORDER BY due_date DESC NULLS LAST;
-- pagination: keyset is faster than OFFSET
-- slow (offset scans & discards rows):
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 10000;
-- fast (keyset):
SELECT * FROM users WHERE id > 10000 ORDER BY id LIMIT 10;
-- random sample
SELECT * FROM users ORDER BY RANDOM() LIMIT 5;Pattern Matching
LIKE is case-insensitive for ASCII by default (a surprising gotcha — use COLLATE BINARY for case sensitivity). GLOB is case-sensitive and supports character classes like [A-Z]. REGEXP isn't built in — you must register a function or load an extension. For full-text search at scale, use FTS5 (see Performance section) instead of LIKE '%term%' which can't use indexes.
-- LIKE: case-insensitive by default for ASCII,
-- wildcards % (any chars) and _ (single char)
SELECT * FROM users WHERE username LIKE 'al%';
SELECT * FROM users WHERE email LIKE '%@gmail.com';
SELECT * FROM users WHERE username LIKE '_lice';
-- case-sensitive LIKE
SELECT * FROM users WHERE username LIKE 'AL%' COLLATE BINARY;
-- GLOB: Unix shell-style, case-SENSITIVE
-- wildcards * and ?, plus [abc] character classes
SELECT * FROM users WHERE username GLOB 'Al*';
SELECT * FROM users WHERE username GLOB '[A-D]*';
-- REGEXP (requires extension or custom function)
SELECT * FROM users WHERE username REGEXP '^a.*e$';
-- substring match
SELECT * FROM users WHERE INSTR(username, 'lic') > 0;CASE & Conditional Logic
CASE is SQL's if/else and works in SELECT, WHERE, ORDER BY, GROUP BY, and HAVING. The 'simple' form matches a value; the 'searched' form evaluates boolean conditions in order. SUM(CASE WHEN ... THEN 1 ELSE 0 END) is the classic pivot/conditional-count idiom. CASE short-circuits at the first matching WHEN, so order matters. ELSE defaults to NULL if omitted.
-- simple CASE (value match)
SELECT name,
CASE role
WHEN 'admin' THEN 'Administrator'
WHEN 'editor' THEN 'Editor'
ELSE 'Regular User'
END AS role_label
FROM users;
-- searched CASE (conditions)
SELECT name,
CASE
WHEN age < 18 THEN 'minor'
WHEN age < 65 THEN 'adult'
ELSE 'senior'
END AS age_group
FROM users;
-- CASE in aggregate (conditional count)
SELECT
COUNT(*) AS total,
SUM(CASE WHEN active = 1 THEN 1 ELSE 0 END) AS active_count,
SUM(CASE WHEN active = 0 THEN 1 ELSE 0 END) AS inactive_count
FROM users;
-- CASE in ORDER BY (custom sort order)
SELECT * FROM products
ORDER BY CASE category
WHEN 'featured' THEN 0
WHEN 'new' THEN 1
ELSE 2
END;DISTINCT & Set Operations
UNION ALL is faster than UNION because UNION performs a deduplication sort — prefer UNION ALL when duplicates are impossible or acceptable. All set operations require compatible column counts and types. SQLite applies INTERSECT/EXCEPT with higher precedence than UNION/UNION ALL; use parentheses to control order. ORDER BY in a compound query applies to the entire result set, not the last SELECT.
-- distinct rows
SELECT DISTINCT category FROM products;
SELECT DISTINCT city, country FROM users;
-- UNION (dedup) and UNION ALL (faster, keeps dups)
SELECT 'user' AS type, id FROM users WHERE active = 1
UNION ALL
SELECT 'admin', id FROM admins;
-- INTERSECT: rows in both
SELECT id FROM users
INTERSECT
SELECT user_id FROM orders;
-- EXCEPT: rows in first but not second
SELECT id FROM users
EXCEPT
SELECT user_id FROM orders;
-- combine with ORDER BY (applies to whole result)
SELECT name FROM users WHERE active = 1
UNION
SELECT name FROM archived_users
ORDER BY name;JOINs
INNER JOIN
INNER JOIN returns only rows with matches in both tables — unmatched rows on either side are dropped. USING (col) is shorthand when both tables have a column of the same name and produces a single merged column. NATURAL JOIN implicitly uses all same-named columns — convenient but fragile (a schema change can silently alter join behavior). Prefer explicit ON for clarity.
-- only matching rows from both tables
SELECT u.username, o.id AS order_id, o.total
FROM users AS u
INNER JOIN orders AS o ON o.user_id = u.id;
-- join with additional filter
SELECT u.username, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.total > 100 AND u.active = 1;
-- USING shorthand (when join columns share a name)
SELECT u.username, o.total
FROM users u
JOIN orders o USING (user_id);
-- natural join (joins on all same-named columns — risky)
SELECT * FROM users NATURAL JOIN profiles;LEFT JOIN
LEFT JOIN preserves every row from the left table; missing right-side columns become NULL. The 'WHERE right.id IS NULL' pattern (anti-join) finds rows without matches — often clearer and faster than NOT IN with subqueries, especially when NULLs are involved. When aggregating across a LEFT JOIN, COUNT(right.id) (not COUNT(*)) counts only matched rows; a user with no orders yields 0, not 1.
-- all users, with their orders (NULLs if no orders)
SELECT u.username, o.id AS order_id, o.total
FROM users AS u
LEFT JOIN orders AS o ON o.user_id = u.id;
-- find users with NO orders (anti-join pattern)
SELECT u.username
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;
-- multiple left joins
SELECT u.username,
COUNT(o.id) AS order_count,
SUM(o.total) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.username;CROSS JOIN & RIGHT/FULL
CROSS JOIN produces the cartesian product — useful for generating combinations (size × color) but dangerous on large tables (n×m rows). SQLite supports RIGHT JOIN (3.39+) but it's rarely used; rewrite as LEFT JOIN for clarity. There's no native FULL OUTER JOIN; emulate it with LEFT JOIN ... UNION ... RIGHT JOIN where the union dedupes the overlapping matched rows.
-- CROSS JOIN: cartesian product (every combination)
SELECT s.size, c.color
FROM sizes s CROSS JOIN colors c;
-- explicit CROSS JOIN (same as comma join)
SELECT a.name, b.name FROM teams a, teams b;
-- RIGHT JOIN (preserves right table) — supported but unusual
SELECT u.username, o.total
FROM orders o
RIGHT JOIN users u ON u.id = o.user_id;
-- SQLite has no FULL OUTER JOIN; emulate with LEFT + UNION:
SELECT u.username, o.id AS order_id
FROM users u LEFT JOIN orders o ON o.user_id = u.id
UNION
SELECT u.username, o.id
FROM users u RIGHT JOIN orders o ON o.user_id = u.id;Self Join
A self join queries the same table twice using aliases to give each copy a role (employee vs. manager, user_a vs. user_b). The 'a.id < b.id' trick avoids matching a row to itself and producing mirrored duplicate pairs. For deep hierarchies (arbitrary depth), a recursive CTE (see CTE section) is the right tool — self joins only handle fixed depths.
-- employees and their managers (same table)
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT,
manager_id INTEGER REFERENCES employees(id)
);
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- find pairs of users in the same city
SELECT a.username AS user_a, b.username AS user_b, a.city
FROM users a
JOIN users b ON a.city = b.city AND a.id < b.id;
-- recursive hierarchy (tree traversal) — see CTE section
-- multi-level category paths
SELECT c.name, p.name AS parent
FROM categories c
LEFT JOIN categories p ON c.parent_id = p.id;Multi-Table Joins
Joins chain left-to-right; each JOIN sees the accumulated result. When mixing LEFT and INNER joins, an INNER JOIN after a LEFT JOIN can re-filter NULLs away, defeating the LEFT's purpose — order carefully. COUNT(DISTINCT col) avoids double-counting across one-to-many joins. Mind join multiplicity: joining two one-to-many tables can produce a row explosion (m × n rows per parent).
-- chain joins across 3+ tables
SELECT u.username, o.id AS order_id, i.name AS item, i.qty
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_items i ON i.order_id = o.id
WHERE u.active = 1
ORDER BY o.id, i.name;
-- mixing JOIN types
SELECT u.username, o.id AS order_id, p.name AS product
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
LEFT JOIN order_items i ON i.order_id = o.id
LEFT JOIN products p ON p.id = i.product_id;
-- join with aggregation
SELECT u.username,
COUNT(DISTINCT o.id) AS orders,
SUM(i.qty * i.price) AS lifetime_value
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN order_items i ON i.order_id = o.id
GROUP BY u.id, u.username;Aggregations & GROUP BY
GROUP BY Basics
GROUP BY collapses rows sharing the grouped columns into one row per group. Every non-aggregated column in SELECT must appear in GROUP BY (SQLite is lenient and picks an arbitrary value, but don't rely on it). Grouping by an expression (like strftime on a date) is great for time-bucket reports. ORDER BY is applied after GROUP BY — use it to sort groups.
-- count users per role
SELECT role, COUNT(*) AS user_count
FROM users
GROUP BY role;
-- multiple aggregates per group
SELECT role,
COUNT(*) AS user_count,
AVG(age) AS avg_age,
MIN(age) AS min_age,
MAX(age) AS max_age,
SUM(age) AS total_age
FROM users
GROUP BY role;
-- group by multiple columns
SELECT role, active, COUNT(*) AS cnt
FROM users
GROUP BY role, active
ORDER BY role, active;
-- group by expression
SELECT strftime('%Y-%m', created) AS month,
COUNT(*) AS signups
FROM users
GROUP BY month
ORDER BY month;Aggregate Functions
COUNT(*) counts rows; COUNT(col) counts non-NULL values; COUNT(DISTINCT col) counts unique non-NULLs. AVG/SUM ignore NULLs. GROUP_CONCAT joins values into a string (default comma separator). TOTAL() always returns a REAL (0.0 for empty input), unlike SUM() which returns NULL for empty input. The FILTER clause (3.30+) is cleaner than CASE for conditional aggregation and can be faster.
SELECT
COUNT(*) AS row_count,
COUNT(email) AS emails_non_null,
COUNT(DISTINCT role) AS distinct_roles,
AVG(age) AS avg_age,
SUM(total) AS revenue,
MIN(created) AS first_signup,
MAX(created) AS last_signup,
GROUP_CONCAT(name) AS all_names,
GROUP_CONCAT(name, ' | ') AS names_piped,
TOTAL(price) AS total_real -- always returns REAL, 0.0 if empty
FROM orders;
-- aggregate with FILTER (SQLite 3.30+)
SELECT
COUNT(*) AS all_orders,
COUNT(*) FILTER (WHERE status='paid') AS paid,
SUM(total) FILTER (WHERE status='paid') AS paid_revenue
FROM orders;HAVING (Filter Groups)
WHERE filters input rows before grouping; HAVING filters groups after aggregation. This ordering matters: a WHERE on an aggregate is illegal. SQLite permits alias references in HAVING (and GROUP BY) for readability, but this is non-standard. The 'group by X having count(*) > 1' pattern is the classic duplicate-finder query.
-- HAVING filters groups (after GROUP BY);
-- WHERE filters rows (before GROUP BY)
SELECT user_id, COUNT(*) AS order_count, SUM(total) AS spent
FROM orders
WHERE status = 'paid' -- filter rows first
GROUP BY user_id
HAVING COUNT(*) >= 3 AND SUM(total) > 100 -- filter groups
ORDER BY spent DESC;
-- HAVING with aliases (SQLite allows this)
SELECT role, COUNT(*) AS n
FROM users
GROUP BY role
HAVING n > 1;
-- find duplicate usernames
SELECT username, COUNT(*) AS dupes
FROM users
GROUP BY username
HAVING COUNT(*) > 1;GROUP_CONCAT
GROUP_CONCAT is SQLite's string-aggregation function (called STRING_AGG or LISTAGG elsewhere). Default separator is comma; specify a custom one as the second argument. ORDER BY inside the aggregate (3.44+) controls concatenation order. DISTINCT deduplicates before joining. The result has a 1GB limit; for huge groups, fetch rows and aggregate in your application instead.
-- concatenate values within a group
SELECT role, GROUP_CONCAT(username) AS members
FROM users
GROUP BY role;
-- members: 'alice,bob,carol'
-- custom separator
SELECT role, GROUP_CONCAT(username, ' | ') AS members
FROM users
GROUP BY role;
-- ordered concatenation (SQLite 3.44+)
SELECT role,
GROUP_CONCAT(username ORDER BY username) AS sorted_members
FROM users
GROUP BY role;
-- distinct values only
SELECT role,
GROUP_CONCAT(DISTINCT city) AS cities
FROM users
GROUP BY role;Pivoting Data
SQLite has no native PIVOT; emulate with SUM(CASE WHEN ...) or the cleaner FILTER clause. Each pivoted column tests one value of the grouping attribute. This pattern transforms long (normalized) data into wide (cross-tab) reports. For fully dynamic pivoting (unknown categories), you must build the SQL string in application code — SQL can't generate columns at runtime.
-- manual pivot with SUM(CASE ...)
SELECT user_id,
SUM(CASE WHEN strftime('%w', created) = '0' THEN 1 ELSE 0 END) AS sun,
SUM(CASE WHEN strftime('%w', created) = '1' THEN 1 ELSE 0 END) AS mon,
SUM(CASE WHEN strftime('%w', created) = '2' THEN 1 ELSE 0 END) AS tue,
SUM(CASE WHEN strftime('%w', created) = '3' THEN 1 ELSE 0 END) AS wed,
SUM(CASE WHEN strftime('%w', created) = '4' THEN 1 ELSE 0 END) AS thu,
SUM(CASE WHEN strftime('%w', created) = '5' THEN 1 ELSE 0 END) AS fri,
SUM(CASE WHEN strftime('%w', created) = '6' THEN 1 ELSE 0 END) AS sat
FROM orders
GROUP BY user_id;
-- pivot with FILTER (cleaner)
SELECT user_id,
COUNT(*) FILTER (WHERE status='paid') AS paid,
COUNT(*) FILTER (WHERE status='pending') AS pending,
COUNT(*) FILTER (WHERE status='refunded') AS refunded
FROM orders
GROUP BY user_id;Subqueries
Scalar Subqueries
A scalar subquery returns exactly one row and one column; it can appear anywhere a value expression is valid. Correlated subqueries reference the outer query and re-execute per row — powerful but potentially slow on large tables (consider a JOIN or window function instead). SQLite is good at optimizing simple correlated subqueries into joins, but always check EXPLAIN QUERY PLAN for hot paths.
-- a subquery returning a single value
SELECT username, age
FROM users
WHERE age > (SELECT AVG(age) FROM users);
-- in SELECT list
SELECT username,
age,
age - (SELECT AVG(age) FROM users) AS age_diff
FROM users;
-- in HAVING
SELECT role, AVG(age) AS avg_age
FROM users
GROUP BY role
HAVING AVG(age) > (SELECT AVG(age) FROM users);
-- correlated scalar subquery (re-evaluated per row)
SELECT username,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;IN & NOT IN Subqueries
IN subqueries are intuitive but watch out for NULLs: if the subquery result contains a NULL, NOT IN evaluates to NULL (treated as false) for every row, returning an empty result. Prefer NOT EXISTS for anti-joins with NULLable columns — it's NULL-safe and often faster. Multi-column IN is concise for matching composite keys.
-- users who have placed an order
SELECT username FROM users
WHERE id IN (SELECT user_id FROM orders);
-- users who have NOT placed an order
SELECT username FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
-- DANGER: NOT IN with NULLs returns no rows!
-- if the subquery returns any NULL, NOT IN matches nothing.
-- Safe version using NOT EXISTS:
SELECT username FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- multi-column IN
SELECT * FROM products
WHERE (category, price) IN (
SELECT category, MIN(price) FROM products GROUP BY category
);EXISTS & NOT EXISTS
EXISTS tests for row existence without fetching data — it stops at the first match, so it's efficient. NOT EXISTS is the NULL-safe way to find non-matching rows (unlike NOT IN). For best performance, ensure an index exists on the column the correlated subquery joins on (orders.user_id in the example). SELECT 1 is convention; the column list is irrelevant to EXISTS.
-- EXISTS: true if subquery returns any row
SELECT username FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.total > 100
);
-- NOT EXISTS: classic anti-join (NULL-safe)
SELECT username FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- correlated EXISTS — usually efficient with an index on
-- the subquery's join column (orders.user_id here)
SELECT p.name FROM products p
WHERE EXISTS (
SELECT 1 FROM order_items i
WHERE i.product_id = p.id AND i.qty > 10
);Derived Tables (Subquery in FROM)
A subquery in FROM (derived table) is a powerful way to stage intermediate results, pre-aggregate, or filter before joining. Derived tables must be aliased. SQLite materializes the subquery (writes it to a temp table) in most cases — for repeated use, a CTE (WITH clause) is often clearer and can be referenced multiple times. SQLite does not allow correlated references into a FROM-subquery.
-- treat a query result as a table
SELECT t.role, t.cnt
FROM (
SELECT role, COUNT(*) AS cnt
FROM users
GROUP BY role
) t
WHERE t.cnt > 1;
-- join a derived table
SELECT u.username, t.total_spent
FROM users u
JOIN (
SELECT user_id, SUM(total) AS total_spent
FROM orders
GROUP BY user_id
) t ON t.user_id = u.id;
-- derived tables must be aliased
SELECT * FROM (SELECT 1 AS x) AS sub;Correlated vs Uncorrelated
Uncorrelated subqueries run once and can be cached; correlated subqueries re-execute per outer row (use indexes to keep them fast). SQLite lacks LATERAL/APPLY; to 'join' a per-row aggregate, pre-aggregate in a derived table and LEFT JOIN it. When a correlated subquery performs poorly, rewriting it as a JOIN with GROUP BY or a window function is the usual fix.
-- UNCORRELATED: runs once, result cached
SELECT username FROM users
WHERE age > (SELECT AVG(age) FROM users);
-- CORRELATED: references outer row, re-runs per outer row
SELECT u.username,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS orders
FROM users u;
-- lateral-style correlation in a JOIN subquery is NOT
-- supported directly; use a scalar subquery in SELECT or
-- rewrite as a join with GROUP BY:
SELECT u.username, COALESCE(t.n, 0) AS orders
FROM users u
LEFT JOIN (
SELECT user_id, COUNT(*) AS n FROM orders GROUP BY user_id
) t ON t.user_id = u.id;Indexes
Creating Indexes
Indexes speed up reads but slow down writes (each INSERT/UPDATE/DELETE updates all indexes). Index column order matters: a composite index on (a, b) helps WHERE a=? and WHERE a=? AND b=?, but NOT WHERE b=? alone (leftmost prefix rule). Unique indexes enforce constraints. SQLite stores indexes as B-trees. Drop unused indexes — they cost space and write performance.
-- single-column index
CREATE INDEX idx_users_email ON users(email);
-- unique index (also enforces uniqueness)
CREATE UNIQUE INDEX idx_users_username ON users(username);
-- composite index (column order matters!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- IF NOT EXISTS for idempotent scripts
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- drop an index
DROP INDEX IF EXISTS idx_users_email;
-- list indexes
.indices users
-- or
SELECT name, sql FROM sqlite_master
WHERE type = 'index' AND tbl_name = 'users';Partial Indexes
Partial indexes index only matching rows, so they're smaller and faster to maintain — ideal when queries always filter on a stable predicate ('active = 1', 'deleted_at IS NULL'). The query's WHERE must match the index's WHERE (or be stricter) for the optimizer to use it. Unique partial indexes enforce uniqueness only on the subset — perfect for 'one active row per parent' constraints.
-- index only active users (smaller, faster)
CREATE INDEX idx_active_users ON users(username)
WHERE active = 1;
-- index only unpaid orders
CREATE INDEX idx_unpaid_orders ON orders(user_id)
WHERE status = 'unpaid';
-- unique partial index: one active session per user
CREATE UNIQUE INDEX idx_one_active_session
ON sessions(user_id) WHERE active = 1;
-- query must match the WHERE for the index to be used
SELECT * FROM users WHERE active = 1 AND username = 'alice';Expression Indexes
Expression indexes index the result of a function or expression, enabling fast lookups on transformed values — but the query must use the exact same expression. Common uses: case-insensitive search (LOWER), date bucketing (strftime), and JSON field extraction (json_extract). Deterministic functions only (no RANDOM() or NOW()). Expression indexes are powerful but make schema changes trickier.
-- index a function result (must match the query expression exactly)
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
-- query that can use it (must use the same expression)
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- index a date extraction for fast monthly queries
CREATE INDEX idx_orders_month ON orders(strftime('%Y-%m', created));
SELECT * FROM orders WHERE strftime('%Y-%m', created) = '2024-06';
-- index a JSON path
CREATE INDEX idx_events_type ON events(json_extract(data, '$.type'));
SELECT * FROM events WHERE json_extract(data, '$.type') = 'login';
-- indexed computed value (collation)
CREATE INDEX idx_users_name_ci ON users(username COLLATE NOCASE);Covering & WITHOUT ROWID
A covering index includes every column a query reads, so SQLite satisfies the query from the index alone (no table lookup). WITHOUT ROWID tables make the PRIMARY KEY a clustered index — rows are stored sorted by key, ideal for key/value workloads and range scans on the PK. Trade-offs: no AUTOINCREMENT, no rowid aliasing, slightly more complex updates. Benchmark before adopting widely.
-- covering index: includes all columns the query needs,
-- so SQLite never reads the table
CREATE INDEX idx_orders_cover ON orders(user_id, status, total);
SELECT user_id, status, total FROM orders WHERE user_id = 1;
-- ^ fully served by the index
-- WITHOUT ROWID tables: store rows IN the index (clustered)
CREATE TABLE kv (
key TEXT PRIMARY KEY,
value TEXT
) WITHOUT ROWID;
-- the PK becomes the table itself; lookups by key are fast
SELECT value FROM kv WHERE key = 'config:theme';
-- good for key/value lookups and read-heavy tables
-- trade-off: no rowid, some features unsupportedIndex Inspection
EXPLAIN QUERY PLAN is the essential tool — 'USING INDEX' means the index is used; 'SCAN' means a full table scan (often bad). sqlite_stat1 (populated by ANALYZE) holds cardinality stats the planner uses to choose indexes. REINDEX rebuilds a bloated or corrupted index without rebuilding the table. Run ANALYZE after large data loads so the planner has fresh statistics for choosing good plans.
-- see if an index is used
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE email = '[email protected]';
-- look for 'SEARCH users USING INDEX idx_users_email'
-- list all indexes on a table with their definitions
SELECT name, sql FROM sqlite_master
WHERE type = 'index' AND tbl_name = 'users';
-- index stats (how often each index is used)
SELECT name, idx_scan, idx_read
FROM sqlite_stat1, sqlite_master
WHERE sqlite_stat1.tbl = 'users';
-- rebuild an index (after corruption or bloat)
REINDEX idx_users_email;
REINDEX; -- rebuild all indexes
-- analyze tables to update query planner stats
ANALYZE;Views
Creating Views
Views are virtual tables defined by a saved query — they don't store data (except temp views), they re-run the underlying SELECT each time. Use them to simplify complex queries, enforce consistent access patterns, or abstract schema changes from applications. Views are read-only by default in SQLite (with constraints on updatable views). DROP VIEW never affects underlying tables.
-- a view is a saved SELECT, queried like a table
CREATE VIEW active_users AS
SELECT id, username, email
FROM users
WHERE active = 1;
SELECT * FROM active_users WHERE age > 18;
-- view with calculated columns
CREATE VIEW user_summary AS
SELECT u.id, u.username,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.username;
-- IF NOT EXISTS for idempotent scripts
CREATE VIEW IF NOT EXISTS active_users AS
SELECT id, username FROM users WHERE active = 1;
-- drop a view
DROP VIEW IF EXISTS active_users;Updatable Views
SQLite allows INSERT/UPDATE/DELETE on simple views (single base table, no JOIN/aggregates/DISTINCT/GROUP BY). The operation is forwarded to the underlying table. SQLite does NOT support WITH CHECK OPTION, so a row inserted through a view may not satisfy the view's WHERE and thus be invisible through it afterward. For complex views needing writes, use INSTEAD OF triggers.
-- simple views (one base table, no aggregates/distinct/etc.)
-- are auto-updatable in SQLite
CREATE VIEW active_users AS
SELECT id, username, email, age FROM users WHERE active = 1;
-- INSERT through the view (active defaults/applies)
INSERT INTO active_users (id, username, email) VALUES (1, 'x', '[email protected]');
-- UPDATE through the view
UPDATE active_users SET age = 30 WHERE id = 1;
-- DELETE through the view
DELETE FROM active_users WHERE id = 1;
-- the WITH CHECK OPTION is NOT supported in SQLite, so
-- inserts/updates that don't match the view's WHERE still
-- land in the base table but vanish from the view.INSTEAD OF Triggers on Views
INSTEAD OF triggers intercept writes to a view and run your custom logic, making any view updatable. This is the standard way to make complex (multi-table, aggregated) views writable. NEW and OLD refer to the incoming and existing rows. INSTEAD OF triggers only fire on views, not base tables. Use them to implement encapsulated business logic or to hide a normalized schema behind a denormalized view.
-- a view joining multiple tables is not auto-updatable,
-- but INSTEAD OF triggers let you define the write behavior
CREATE VIEW user_orders AS
SELECT u.id AS user_id, u.username, o.id AS order_id, o.total
FROM users u LEFT JOIN orders o ON o.user_id = u.id;
-- route INSERTs on the view to the orders table
CREATE TRIGGER trg_user_orders_insert
INSTEAD OF INSERT ON user_orders
FOR EACH ROW
BEGIN
INSERT INTO orders (id, user_id, total)
VALUES (NEW.order_id, NEW.user_id, NEW.total);
END;
-- now this works:
INSERT INTO user_orders (user_id, order_id, total)
VALUES (1, 100, 49.99);Temporary Views
Temporary views live only for the current database connection and are automatically dropped on disconnect — perfect for staging complex ad-hoc queries without cluttering the shared schema. They're stored in a separate temp schema, so other connections (even to the same file) don't see them. TEMP views can reference TEMP tables, which is useful for session-scoped ETL pipelines.
-- TEMP view exists only for the current connection
CREATE TEMP VIEW recent_orders AS
SELECT * FROM orders WHERE created > datetime('now', '-7 days');
-- the view vanishes when the connection closes
-- (no other connection can see it)
-- also valid: CREATE TEMPORARY VIEW
CREATE TEMPORARY VIEW my_scratch AS
SELECT id, username FROM users WHERE role = 'admin';
-- use case: ad-hoc analysis without polluting the schema
SELECT * FROM recent_orders WHERE total > 100;
-- list views
SELECT name, sql FROM sqlite_master
WHERE type = 'view';
SELECT name, sql FROM temp.sqlite_master
WHERE type = 'view'; -- temp viewsMaterialized Views (Emulated)
SQLite has no native materialized view, but a regular table refreshed on a schedule achieves the same thing. For atomic refresh, build a new table in a transaction and rename it — readers see the old version until commit, then the new version. Add indexes after the rebuild. Materialized views trade write/storage cost for read speed — ideal for expensive aggregates queried frequently.
-- SQLite has no native materialized views; emulate with a
-- real table that you refresh periodically
-- 1) create the 'materialized' table
CREATE TABLE mv_user_summary AS
SELECT u.id, u.username,
COUNT(o.id) AS order_count,
COALESCE(SUM(o.total), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.username;
-- 2) add an index for fast lookups
CREATE INDEX idx_mv_user_summary_id ON mv_user_summary(id);
-- 3) refresh (full rebuild)
DELETE FROM mv_user_summary;
INSERT INTO mv_user_summary
SELECT u.id, u.username, COUNT(o.id), COALESCE(SUM(o.total), 0)
FROM users u LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.username;
-- 4) refresh atomically with a name swap
BEGIN;
CREATE TABLE mv_user_summary_new AS SELECT ...;
DROP TABLE mv_user_summary;
ALTER TABLE mv_user_summary_new RENAME TO mv_user_summary;
COMMIT;Triggers
BEFORE / AFTER Triggers
BEFORE triggers run before the row is written — use them to validate (RAISE ABORT) or transform NEW values. AFTER triggers run once the row is committed — use them for auditing, cascading effects, or denormalization. FOR EACH ROW is the only option (no statement-level triggers). NEW holds the incoming row (INSERT/UPDATE); OLD holds the prior row (UPDATE/DELETE). The WHEN clause conditionally fires the trigger.
-- AFTER INSERT: audit log
CREATE TRIGGER trg_users_audit
AFTER INSERT ON users
FOR EACH ROW
BEGIN
INSERT INTO users_audit (user_id, action, at)
VALUES (NEW.id, 'insert', datetime('now'));
END;
-- BEFORE INSERT: validate / normalize data
CREATE TRIGGER trg_users_lower_email
BEFORE INSERT ON users
FOR EACH ROW
BEGIN
SELECT RAISE(ABORT, 'email cannot be empty')
WHERE NEW.email = '';
END;
-- AFTER UPDATE: log changes
CREATE TRIGGER trg_users_update_log
AFTER UPDATE ON users
FOR EACH ROW
WHEN OLD.email IS NOT NEW.email
BEGIN
INSERT INTO email_changes (user_id, old_email, new_email, at)
VALUES (NEW.id, OLD.email, NEW.email, datetime('now'));
END;Triggers for Cascading Effects
Triggers can maintain denormalized counters, summary tables, and updated_at timestamps automatically. Beware: recursive trigger chains are possible (a trigger updating its own table), so use PRAGMA recursive_triggers to control this (default OFF for backward compatibility). Trigger order for multiple triggers on the same event follows creation order; there's no BEFORE/AFTER priority across triggers of the same timing.
-- maintain a denormalized counter
CREATE TRIGGER trg_orders_count_inc
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
UPDATE users
SET order_count = order_count + 1
WHERE id = NEW.user_id;
END;
CREATE TRIGGER trg_orders_count_dec
AFTER DELETE ON orders
FOR EACH ROW
BEGIN
UPDATE users
SET order_count = order_count - 1
WHERE id = OLD.user_id;
END;
-- keep an 'updated_at' column fresh
CREATE TRIGGER trg_users_touch
AFTER UPDATE ON users
FOR EACH ROW
WHEN NEW.updated_at IS OLD.updated_at
BEGIN
UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id;
END;INSTEAD OF Triggers (Views)
INSTEAD OF triggers run instead of the original INSERT/UPDATE/DELETE on a view, letting you route writes to one or more underlying tables. This is the canonical way to make complex views writable. They fire per row of the view. Only INSTEAD OF triggers can be defined on views; BEFORE/AFTER triggers require base tables. Useful for API-style abstraction layers over a normalized schema.
-- make a multi-table view writable
CREATE VIEW order_detail AS
SELECT o.id AS order_id, o.total, u.username, i.product_name
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN order_items i ON i.order_id = o.id;
CREATE TRIGGER trg_order_detail_update
INSTEAD OF UPDATE ON order_detail
FOR EACH ROW
BEGIN
UPDATE orders SET total = NEW.total WHERE id = NEW.order_id;
UPDATE order_items SET product_name = NEW.product_name
WHERE order_id = NEW.order_id;
END;
-- now UPDATE on the view routes to both tables
UPDATE order_detail SET total = 99.99, product_name = 'Widget'
WHERE order_id = 5;RAISE & Error Handling
RAISE controls how a trigger failure propagates: ABORT (most common) cancels the statement and rolls back its changes, ROLLBACK rolls back the entire transaction, FAIL aborts the statement but keeps prior successful statements in the transaction, and IGNORE skips just the offending row. RAISE(IGNORE) is a neat way to silently drop invalid rows during bulk loads.
-- RAISE aborts the statement with a custom error
CREATE TRIGGER trg_check_age
BEFORE INSERT ON users
FOR EACH ROW
WHEN NEW.age < 0
BEGIN
SELECT RAISE(ABORT, 'age must be non-negative');
END;
-- ROLLBACK: undo the whole transaction, statement fails
CREATE TRIGGER trg_rollback_example
BEFORE UPDATE ON accounts
FOR EACH ROW
WHEN NEW.balance < 0
BEGIN
SELECT RAISE(ROLLBACK, 'balance cannot go negative');
END;
-- FAIL: abort the current statement only (like ABORT)
-- IGNORE: skip this row, continue with the rest
CREATE TRIGGER trg_skip_invalid
BEFORE INSERT ON logs
FOR EACH ROW
WHEN NEW.level NOT IN ('info','warn','error')
BEGIN
SELECT RAISE(IGNORE);
END;Managing Triggers
Triggers are stored in sqlite_master (or temp.sqlite_master for TEMP triggers). DROP TRIGGER removes them; there's no ALTER TRIGGER — drop and recreate. TEMP triggers are connection-local and disappear on disconnect, useful for debugging. There's no built-in way to disable a trigger temporarily except by dropping it; for conditional execution, use the WHEN clause. Multiple triggers on the same event fire in creation order within their timing.
-- list all triggers
SELECT name, tbl_name, sql FROM sqlite_master
WHERE type = 'trigger';
-- list triggers on a specific table
SELECT name, sql FROM sqlite_master
WHERE type = 'trigger' AND tbl_name = 'users';
-- drop a trigger
DROP TRIGGER IF EXISTS trg_users_audit;
-- triggers in a TEMP schema (connection-local)
CREATE TEMP TRIGGER trg_temp_log
AFTER INSERT ON users
FOR EACH ROW
BEGIN
SELECT 'inserted ' || NEW.id;
END;
-- trigger timing order: BEFORE triggers fire in
-- creation order, then AFTER triggers in creation order.PRAGMA Settings
PRAGMA Basics
PRAGMA is SQLite's proprietary configuration interface — settings control journaling, caching, foreign keys, integrity, and the query planner. Settings are session-scoped (per-connection) unless the docs say otherwise; setting them inside a transaction may be ignored. Some PRAGMAs (journal_mode, page_size) only take effect outside transactions or before the table is created. Use PRAGMA compile_options to see what your build supports.
-- PRAGMA is SQLite's configuration knob (CLI and APIs)
-- read a setting
PRAGMA journal_mode;
PRAGMA cache_size;
PRAGMA foreign_keys;
-- set a setting (session scope unless noted)
PRAGMA foreign_keys = ON;
PRAGMA cache_size = -20000; -- negative = KB, positive = pages
PRAGMA temp_store = MEMORY;
-- many PRAGMAs are no-ops below a certain SQLite version
-- check the compile-time options
PRAGMA compile_options;
-- e.g. ENABLE_FTS5, ENABLE_JSON1, MAX_ATTACHED, etc.
-- PRAGMAs are NOT part of SQL standard and are SQLite-only.Foreign Keys
Foreign key enforcement is OFF by default in SQLite — a notorious gotcha for users coming from other databases. You must enable it on every connection with PRAGMA foreign_keys = ON; it cannot be set inside a transaction. This setting exists because early schemas (pre-FK support) would otherwise break. Always enable it in production. defer_foreign_keys helps when importing data in dependency-violating order.
-- foreign keys are OFF by default in SQLite!
PRAGMA foreign_keys = ON; -- enable per connection
-- check current state
PRAGMA foreign_keys;
-- inspect FK definitions on a table
PRAGMA foreign_key_list(orders);
-- returns: id, seq, table, from, to, on_update, on_delete, match
-- enable FKs in your app's connection setup, every time
-- (this is a frequent source of bugs when moving from MySQL/PG)
-- deferred FK checking (checked at COMMIT, not per statement)
PRAGMA defer_foreign_keys = ON;
-- or declare DEFERRABLE INITIALLY DEFERRED on the FKJournal Mode & WAL
WAL (Write-Ahead Logging) is the recommended journal mode for most apps: readers and writers don't block each other, and durability is good. It creates -wal and -shm sidecar files alongside the main DB. WAL is persistent per database file (set it once). Use wal_checkpoint(TRUNCATE) to fold WAL contents back into the main file and reclaim space. Avoid WAL on network filesystems — it requires shared memory.
-- WAL: write-ahead logging (best for most apps)
PRAGMA journal_mode = WAL;
-- benefits: readers never block writers, writers never block
-- readers, crash recovery is fast
-- trade-off: creates -wal and -shm sidecar files
-- other modes: DELETE (default), TRUNCATE, PERSIST, MEMORY, OFF
PRAGMA journal_mode = DELETE;
-- WAL checkpoint: merge -wal back into the main file
PRAGMA wal_checkpoint;
PRAGMA wal_checkpoint(TRUNCATE); -- also shrinks the -wal file
-- auto-checkpoint threshold (pages of WAL before auto-checkpoint)
PRAGMA wal_autocheckpoint = 1000;
-- WAL is persistent: setting it once keeps it for that DB file.Synchronous & Durability
synchronous controls how aggressively SQLite calls fsync. FULL is safest (every commit fsyncs); NORMAL is the recommended value when using WAL (fsync at checkpoint, not per commit, with negligible corruption risk in practice); OFF risks database corruption on power loss — only for truly disposable data or rebuildable caches. page_size should be set before the schema is created; changing it later requires VACUUM.
-- how thoroughly SQLite flushes to disk on commit
PRAGMA synchronous; -- read current value
PRAGMA synchronous = FULL; -- safest, slowest (default for rollback journal)
PRAGMA synchronous = NORMAL; -- safe with WAL, fast (recommended for WAL)
PRAGMA synchronous = OFF; -- fastest; corruption risk on power loss
PRAGMA synchronous = EXTRA; -- even more careful than FULL
-- trade-off: durability vs speed
-- FULL: fsync at every commit (survives OS crash)
-- NORMAL (WAL): fsync at checkpoint, not every commit
-- OFF: no fsync — fast but may corrupt on power loss
-- page size (set BEFORE creating tables, ideally)
PRAGMA page_size = 4096;
PRAGMA page_size;Cache, Memory & Temp Store
cache_size is the in-memory page cache — bigger is generally better for read-heavy workloads; negative values are in KB, positive in pages. temp_store = MEMORY moves intermediate results (sorts, temp tables) to RAM, speeding up large aggregations. mmap_size enables memory-mapped I/O for reads, which can speed up large read-only databases. Tune these based on your app's memory budget and access pattern.
-- page cache (negative = KB, positive = pages)
PRAGMA cache_size = -65536; -- 64 MB cache
PRAGMA cache_size = 20000; -- 20000 pages
-- where temp tables and intermediate results live
PRAGMA temp_store = DEFAULT; -- compile-time default
PRAGMA temp_store = FILE; -- temp files
PRAGMA temp_store = MEMORY; -- all temp data in RAM
-- mmap memory-mapped I/O for reads
PRAGMA mmap_size = 268435456; -- 256 MB
-- soft heap limit for the SQLite library
PRAGMA soft_heap_limit = 100000000; -- 100 MB
-- store prepared statements in memory between calls
PRAGMA cache_spill;Integrity & Schema Info
integrity_check validates the entire database structure and content (slow but thorough); quick_check skips content validation and is much faster. Run integrity_check during backups or after crashes. table_info reports column metadata (use table_xinfo for hidden columns like FTS5 rowid). index_list and index_info reveal which indexes exist and their columns. database_list shows attached databases.
-- full integrity check (slow on large DBs)
PRAGMA integrity_check;
-- returns 'ok' or a list of problems
-- quick check (faster, less thorough)
PRAGMA quick_check;
-- list tables / schema
SELECT name, sql FROM sqlite_master WHERE type = 'table';
-- table info: columns, types, notnull, default, pk
PRAGMA table_info(users);
-- returns: cid, name, type, notnull, dflt_value, pk
-- extended table info (includes hidden columns from FTS/etc.)
PRAGMA table_xinfo(users);
-- index list with origin and unique flag
PRAGMA index_list(users);
PRAGMA index_info(idx_users_email);
-- database file header info
PRAGMA database_list;Date & Time Functions
date / time / datetime
SQLite has no native DATE type; dates are TEXT in ISO-8601 ('YYYY-MM-DD HH:MM:SS'). The date/time functions accept many input formats (ISO, slashes, month names, Unix timestamps via 'unixepoch' modifier). 'now' is evaluated once per statement, not per row — every row in a multi-row UPDATE gets the same timestamp, which is usually what you want. Store in UTC, format for display with localtime.
-- current date/time (UTC by default)
SELECT date('now'); -- '2024-06-15'
SELECT time('now'); -- '14:30:00'
SELECT datetime('now'); -- '2024-06-15 14:30:00'
SELECT julianday('now'); -- 2460476.1042 (days since 4713 BC)
-- parse various formats
SELECT date('2024-06-15');
SELECT date('2024/06/15');
SELECT date('June 15, 2024');
SELECT datetime('2024-06-15 14:30:00.123');
SELECT date('2024-06-15','unixepoch'); -- from Unix timestamp (seconds)
-- Unix timestamp to datetime
SELECT datetime(1718450400, 'unixepoch');
SELECT datetime(1718450400, 'unixepoch', 'localtime');strftime Formatting
strftime is the universal date formatter — format codes mirror C's strftime. Use it to extract parts of a date (year, month, weekday) for grouping or to render a custom display format. For grouping by month, strftime('%Y-%m', created) yields 'YYYY-MM' which sorts and groups naturally. SQLite doesn't have DAYNAME()/MONTHNAME(); combine strftime('%w', ...) with a CASE if you need weekday names.
-- strftime is the most flexible formatter
-- strftime(format, timestring, modifier, modifier, ...)
SELECT strftime('%Y-%m-%d', 'now'); -- '2024-06-15'
SELECT strftime('%Y-%m-%d %H:%M', 'now'); -- '2024-06-15 14:30'
SELECT strftime('%H:%M:%S', 'now'); -- '14:30:00'
-- common format codes
-- %Y year (4-digit) %m month (01-12) %d day (01-31)
-- %H hour (00-23) %M minute (00-59) %S second (00-59)
-- %j day of year %w day of week (0=Sun)
-- %W week of year %p AM/PM %I hour (01-12)
-- get the day name
SELECT strftime('%w', '2024-06-15'); -- '6' (Saturday)
SELECT strftime('%Y-%m', 'now'); -- month bucket for groupingModifiers (Arithmetic)
Modifiers chain left-to-right to shift or align dates. Intervals are '+N units' / '-N units' (units: days, hours, minutes, seconds, months, years). 'start of month/year/day' snaps to the period boundary — perfect for monthly reports. 'weekday N' returns the date of the next weekday N (today if today matches). Combining 'start of month', '+1 month', '-1 day' yields the last day of the current month — a classic idiom.
-- shift a date by an interval
SELECT date('now', '+1 day'); -- tomorrow
SELECT date('now', '-1 month'); -- one month ago
SELECT date('now', '+1 year', '+1 day');-- next year + 1 day
SELECT datetime('now', '+2 hours', '+30 minutes');
-- start of period
SELECT date('now', 'start of month'); -- first day of month
SELECT date('now', 'start of year'); -- first day of year
SELECT date('now', 'start of day'); -- midnight today
-- weekday: next occurrence of a given weekday (0=Sun, 1=Mon, ... 6=Sat)
SELECT date('now', 'weekday 1'); -- next Monday
SELECT date('now', 'weekday 0', '+7 days'); -- Sunday after this one
-- combine modifiers
SELECT date('now', 'start of month', '+1 month', '-1 day'); -- last day of monthUnix Time & Julian Day
Unix timestamps are integers/seconds since the epoch; unixepoch() (3.38+) returns an integer directly. Julian day is a continuous fractional day count — subtracting two julianday values gives the elapsed time in days (multiply by 86400 for seconds). For age calculations, dividing by 365.25 accounts for leap years approximately; for legal precision, compare year/month/day components explicitly.
-- Unix timestamp (seconds since 1970-01-01 UTC)
SELECT strftime('%s', 'now'); -- current Unix time (text)
SELECT unixepoch('now'); -- integer Unix time (SQLite 3.38+)
SELECT datetime(1718450400, 'unixepoch'); -- back to datetime
-- Julian day (fractional days since 4713-01-01 BC)
SELECT julianday('now'); -- 2460476.6042
SELECT julianday('2024-06-15') - julianday('2024-06-10'); -- 5.0 (days between)
-- compute elapsed time in seconds
SELECT (julianday('now') - julianday('2024-01-01')) * 86400.0 AS seconds_elapsed;
-- human-readable age from a birthdate
SELECT CAST((julianday('now') - julianday('1990-05-20')) / 365.25 AS INT) AS age;Time Zones & localtime
SQLite has no timezone database — 'now' is UTC, and 'localtime'/'utc' modifiers rely on the host OS's timezone setting. The recommended pattern: always store timestamps in UTC (datetime('now') is UTC) and convert to local time only at display time with the 'localtime' modifier. This avoids ambiguity across servers in different zones and during daylight-saving transitions. Never store local time.
-- 'now' is always UTC; convert to local time for display
SELECT datetime('now', 'localtime'); -- local wall-clock time
SELECT datetime('now'); -- UTC
-- convert a stored UTC timestamp to local time
SELECT datetime(created, 'localtime') FROM users;
-- convert local time back to UTC for storage
SELECT datetime('2024-06-15 14:30:00', 'utc');
-- best practice: store UTC, format for display
CREATE TABLE events (ts TEXT DEFAULT (datetime('now'))); -- UTC
-- display:
SELECT datetime(ts, 'localtime') AS local_ts FROM events;
-- timezone is determined by the OS; SQLite has no TZ database.String Functions
Length & Substring
length() counts characters for TEXT and bytes for BLOB — for UTF-8 byte length, cast to BLOB first. substr() is 1-indexed; negative start counts from the end. SQLite has no native LPAD/RPAD — emulate with string concatenation and substr. Watch for byte-vs-character confusion when truncating multi-byte text: substr operates on characters, so it's safe, but byte-level slicing isn't.
SELECT length('hello'); -- 5
SELECT length('日本語'); -- 3 (characters, not bytes)
SELECT length(x'00ff'); -- 2 (blob length in bytes)
SELECT substr('hello world', 7); -- 'world' (1-indexed)
SELECT substr('hello world', 1, 5); -- 'hello' (length 5)
SELECT substr('hello', -3); -- 'llo' (negative = from end)
SELECT substr('hello', -3, 2); -- 'll'
-- byte length (vs character length)
SELECT length('日本語'); -- 3 (characters)
SELECT length(CAST('日本語' AS BLOB)); -- 9 (UTF-8 bytes)
-- left/right pad (workaround; no native LPAD/RPAD)
SELECT substr('0000' || '42', -4, 4); -- '0042'Case & Trimming
upper()/lower() only affect ASCII letters by default — non-ASCII characters are unchanged unless ICU is loaded. trim() removes leading/trailing whitespace by default, or specific characters given as the second argument. replace() does literal substring replacement (no regex or patterns). There's no native TRANSLATE() for character-set substitution — chain replace() calls or use a custom function.
SELECT upper('hello'); -- 'HELLO'
SELECT lower('HELLO'); -- 'hello'
SELECT upper('日本語'); -- '日本語' (no case to change)
SELECT trim(' hi '); -- 'hi' (both sides)
SELECT ltrim(' hi '); -- 'hi ' (left only)
SELECT rtrim(' hi '); -- ' hi' (right only)
-- trim specific characters
SELECT trim('xxhelloxx', 'x'); -- 'hello'
SELECT ltrim('aaabbb', 'a'); -- 'bbb'
-- replace characters
SELECT replace('a-b-c', '-', '/'); -- 'a/b/c'
SELECT replace('Hello World', 'o', '0'); -- 'Hell0 W0rld'Concatenation & printf
The || operator is the standard SQL concatenator, but it propagates NULL (NULL || x = NULL) — wrap with COALESCE when NULLs are possible. printf()/format() brings C-style formatting and is the cleanest way to build strings with padding, number formatting, and comma separators. quote() returns a string safely quoted and escaped for embedding in SQL — useful when building dynamic SQL.
-- || operator concatenates
SELECT 'Hello' || ' ' || 'World'; -- 'Hello World'
SELECT username || ' <' || email || '>' FROM users;
-- NULL propagates through || (NULL || 'x' IS NULL)
SELECT 'a' || NULL || 'b'; -- NULL
-- use COALESCE to handle NULLs:
SELECT COALESCE(username, '') || ' <' || COALESCE(email, '') || '>';
-- printf-style formatting (also called format())
SELECT printf('%s has %d orders', 'alice', 5); -- 'alice has 5 orders'
SELECT format('%,d', 1234567); -- '1,234,567' (3.38+)
SELECT printf('%5.2f', 3.14159); -- ' 3.14'
SELECT printf('%04d', 42); -- '0042'
-- quote() escapes a string for SQL
SELECT quote("It's alive"); -- '''It''s alive'''Splitting & Searching
instr() finds the first occurrence of a substring (1-indexed; 0 if not found). SQLite has no native SPLIT_PART — emulate with substr + instr or a recursive CTE. The 'count occurrences' idiom subtracts lengths before/after removing the delimiter. char() builds a string from code points; unicode() returns the code point of the first character. For full text splitting, load a JSON array via json_each.
-- find position of substring (1-indexed; 0 if not found)
SELECT instr('hello world', 'world'); -- 7
SELECT instr('hello', 'xyz'); -- 0
-- emulate split by getting the Nth field
-- (no native SPLIT_PART; use a CTE or substring + instr)
SELECT
substr('a,b,c,d', 1, instr('a,b,c,d', ',') - 1) AS first; -- 'a'
-- count occurrences of a substring
SELECT (length('a,b,c,d') - length(replace('a,b,c,d', ',', ''))) / length(',') AS commas;
-- 3
-- reverse a string
SELECT reverse('hello'); -- 'olleh'
-- character from integer code
SELECT char(65, 66, 67); -- 'ABC'
SELECT unicode('A'); -- 65LIKE / GLOB / Collations
LIKE is case-insensitive for ASCII by default (a frequent surprise); use COLLATE BINARY for case sensitivity. GLOB is always case-sensitive and supports shell-style character classes. NOCASE is a built-in collation for case-insensitive comparison; apply it to a column's index so case-insensitive queries use the index. For non-ASCII case folding, load the ICU extension or store a lowercased copy column with an index.
-- LIKE: case-insensitive (ASCII), wildcards % and _
SELECT * FROM users WHERE username LIKE 'al%';
SELECT * FROM users WHERE username LIKE '_lice';
-- case-sensitive LIKE
SELECT * FROM users WHERE username LIKE 'Al%' COLLATE BINARY;
-- make LIKE case-insensitive for non-ASCII too (needs ICU)
-- or store a lowercased copy and query that
SELECT * FROM users WHERE LOWER(username) = LOWER('Alice');
-- GLOB: case-sensitive, Unix-style (* ? [abc] [a-z])
SELECT * FROM users WHERE username GLOB 'A*';
SELECT * FROM users WHERE username GLOB '[A-D]*';
-- built-in collations: BINARY (default), NOCASE, RTRIM
CREATE INDEX idx_users_name_ci ON users(username COLLATE NOCASE);
SELECT * FROM users WHERE username = 'ALICE' COLLATE NOCASE;
-- COLLATE in ORDER BY
SELECT * FROM users ORDER BY username COLLATE NOCASE;JSON Functions
Extracting Values
json_extract (and the -> / ->> operators, 3.38+) are the workhorses for reading JSON. $ is the root; $.key accesses an object property; $.arr[i] accesses an array element (0-indexed). ->> returns a scalar (text/integer/real), -> returns JSON (so '$.b' on an array yields the array as JSON). Store frequently-queried JSON fields as regular columns, or add an expression index on json_extract for performance.
-- json_extract: get a value by JSON path
SELECT json_extract('{"a": 1, "b": [10, 20, 30]}', '$.a'); -- 1
SELECT json_extract('{"a": 1, "b": [10, 20, 30]}', '$.b[1]'); -- 20
SELECT json_extract('{"a": 1, "b": [10, 20, 30]}', '$.b'); -- '[10,20,30]'
-- -> returns JSON; ->> returns scalar (text/integer/etc.)
SELECT '{"a": 1, "b": "x"}' -> '$.a'; -- 1 (as JSON)
SELECT '{"a": 1, "b": "x"}' ->> '$.a'; -- 1 (as scalar)
SELECT '{"a": 1, "b": "x"}' ->> '$.b'; -- 'x' (as text)
-- nested paths
SELECT json_extract('{"u": {"name": "alice"}}', '$.u.name'); -- 'alice'
-- array length
SELECT json_array_length('[1, 2, 3]'); -- 3
SELECT json_array_length('{"a": [1,2]}', '$.a'); -- 2
-- get object keys
SELECT json_each.name FROM json_each('{"a":1,"b":2}'); -- 'a', 'b'Building JSON
json_object and json_array construct JSON from SQL values (NULLs become JSON null, not omitted). json_group_array and json_group_object (3.38+) are aggregate functions that build JSON from query rows — invaluable for producing JSON APIs directly from SQL. Nest constructors to build complex documents. Use json_quote to safely embed a string as a JSON string literal.
-- build a JSON object
SELECT json_object('id', 1, 'name', 'alice', 'active', 1);
-- {"id":1,"name":"alice","active":1}
-- build a JSON array
SELECT json_array(1, 2, 3, 'four', NULL);
-- [1,2,3,"four",null]
-- nest them
SELECT json_object(
'user', json_object('id', 1, 'name', 'alice'),
'roles', json_array('admin', 'editor')
);
-- aggregate rows into a JSON array
SELECT json_group_array(username) FROM users WHERE active = 1;
-- ["alice","bob","carol"]
-- aggregate into a JSON object (key:value)
SELECT json_group_object(username, email) FROM users WHERE active = 1;
-- {"alice":"[email protected]","bob":"[email protected]"}
-- quote a string as JSON
SELECT json_quote('hello'); -- '"hello"'Modifying JSON
json_set/insert/replace distinguish insert-vs-update behavior; json_remove deletes paths. These return NEW JSON values — SQLite JSON is immutable, so to persist changes you UPDATE the column with the result: UPDATE t SET data = json_set(data, '$.x', 1) WHERE id = 5. json_patch (RFC 7396) merges objects; null values in the patch delete keys. For large documents, consider storing hot fields as separate columns.
-- json_set: insert or update a path
SELECT json_set('{"a": 1}', '$.b', 2);
-- {"a":1,"b":2}
SELECT json_set('{"a": 1}', '$.a', 99);
-- {"a":99}
-- json_insert: insert only if path does NOT exist
SELECT json_insert('{"a": 1}', '$.a', 99, '$.b', 2);
-- {"a":1,"b":2} (a unchanged because it exists)
-- json_replace: update only if path EXISTS
SELECT json_replace('{"a": 1}', '$.a', 99, '$.b', 2);
-- {"a":99} (b not added because it doesn't exist)
-- json_remove: delete a path
SELECT json_remove('{"a": 1, "b": 2}', '$.b');
-- {"a":1}
-- json_patch: merge per RFC 7396
SELECT json_patch('{"a": 1, "b": 2}', '{"b": 3, "c": 4}');
-- {"a":1,"b":3,"c":4}Querying JSON Arrays
json_each and json_tree are virtual table-valued functions that expand JSON into rows. json_each handles a single array/object; json_tree recursively walks the entire structure, exposing fullkey paths. This unlocks set-based operations on JSON arrays — filtering, joining, and aggregating. To query arrays-of-objects in a column, use a LATERAL-style join: FROM users u, json_each(u.tags) e.
-- json_each: virtual table, one row per array element
SELECT value FROM json_each('[10, 20, 30]');
-- 10, 20, 30
-- with index
SELECT key, value FROM json_each('["a","b","c"]');
-- 0,'a' 1,'b' 2,'c'
-- query an array stored in a column
SELECT u.username, e.value AS tag
FROM users u, json_each(u.tags) e
WHERE e.value LIKE 'admin%';
-- aggregate back to JSON
SELECT json_group_array(value)
FROM json_each('[10, 20, 30]')
WHERE value > 15;
-- [20,30]
-- json_tree: recursively walk nested structures
SELECT fullkey, value
FROM json_tree('{"a": {"b": [1, 2]}}');
-- '$','{"a":{"b":[1,2]}}'
-- '$.a','{"b":[1,2]}'
-- '$.a.b[0]',1
-- '$.a.b[1]',2JSON Indexing & Validation
Index json_extract expressions for fast JSON field lookups — the query must use the identical expression to benefit. json_valid() returns 1/0; use it in a CHECK constraint to enforce JSON-only columns. json_type() reports the JSON type at a path ('object', 'array', 'integer', 'real', 'string', 'boolean', 'null'). For heavy JSON workloads, denormalize hot fields into regular indexed columns and keep JSON for the flexible tail.
-- expression index on a JSON field for fast lookups
CREATE INDEX idx_events_type
ON events(json_extract(data, '$.type'));
SELECT * FROM events WHERE json_extract(data, '$.type') = 'login';
-- unique index on a JSON field
CREATE UNIQUE INDEX idx_users_email_json
ON users(json_extract(profile, '$.email'));
-- validate JSON
SELECT json_valid('{"a": 1}'); -- 1
SELECT json_valid('{a: 1}'); -- 0 (unquoted keys)
-- JSON type of a path
SELECT json_type('{"a": 1, "b": [1,2]}', '$.a'); -- 'integer'
SELECT json_type('{"a": 1, "b": [1,2]}', '$.b'); -- 'array'
-- check constraint for valid JSON columns
CREATE TABLE events (
id INTEGER PRIMARY KEY,
data TEXT CHECK (json_valid(data))
);Window Functions
OVER Clause Basics
Window functions (SQLite 3.25+) compute across a set of rows related to the current row, without collapsing them like GROUP BY does. OVER () means 'all rows'; OVER (ORDER BY ...) defines ordering for ranking. ROW_NUMBER is unique; RANK leaves gaps after ties; DENSE_RANK doesn't. Window functions appear in SELECT and ORDER BY, not WHERE — filter with an outer query or CTE.
-- window functions: aggregate OVER a 'window' of rows
-- without collapsing rows (unlike GROUP BY)
SELECT username, age,
AVG(age) OVER () AS overall_avg
FROM users;
-- ROW_NUMBER: unique sequential number per row
SELECT username,
ROW_NUMBER() OVER (ORDER BY created) AS row_num
FROM users;
-- RANK / DENSE_RANK
SELECT username, age,
RANK() OVER (ORDER BY age DESC) AS rank,
DENSE_RANK() OVER (ORDER BY age DESC) AS dense_rank
FROM users;PARTITION BY
PARTITION BY divides rows into groups for the window function, like GROUP BY but without collapsing — each row retains its identity while getting a per-group aggregate. The 'top N per group' pattern (ROW_NUMBER + outer WHERE rn <= N) is the canonical solution for 'top 3 orders per customer', 'latest 5 posts per author', etc. It's typically much faster than correlated subqueries.
-- per-group ranking / aggregation
SELECT username, role, age,
ROW_NUMBER() OVER (PARTITION BY role ORDER BY age DESC) AS rank_in_role,
AVG(age) OVER (PARTITION BY role) AS avg_role_age
FROM users;
-- top N per group (common pattern)
SELECT * FROM (
SELECT user_id, total,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY total DESC) AS rn
FROM orders
) WHERE rn <= 3;
-- percent of group total
SELECT username, role,
age * 1.0 / SUM(age) OVER (PARTITION BY role) AS pct_of_role_total
FROM users;Frame Specifications
A frame defines which rows the window function sees, relative to the current row. UNBOUNDED PRECEDING..CURRENT ROW gives a running total. N PRECEDING..N FOLLOWING gives a sliding window (moving average). The default frame with ORDER BY uses RANGE, which includes peer rows (same ORDER BY value); ROWS is strict row-counting. Misunderstanding frames vs ORDER BY is a common source of incorrect analytics.
-- RUNNING TOTAL: cumulative sum over rows so far
SELECT username, total,
SUM(total) OVER (ORDER BY created
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running
FROM orders;
-- MOVING AVERAGE: 3-row window centered on current row
SELECT username, total,
AVG(total) OVER (ORDER BY created
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS moving_avg
FROM orders;
-- default frame for ORDER BY windows:
-- RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
-- default frame (no ORDER BY):
-- ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
-- ROWS vs RANGE: ROWS counts rows; RANGE includes peers.LAG & LEAD
LAG and LEAD access other rows relative to the current one — essential for time-series analysis (day-over-day deltas, streak detection). The optional third argument is a default for out-of-range rows. FIRST_VALUE/LAST_VALUE return values from the frame edges; note LAST_VALUE needs an explicit UNBOUNDED FOLLOWING frame or it returns the current row's value (a classic gotcha).
-- compare current row to previous / next row
SELECT username, created,
LAG(created, 1) OVER (ORDER BY created) AS prev_created,
LEAD(created, 1) OVER (ORDER BY created) AS next_created
FROM users;
-- difference from previous value (time-series analysis)
SELECT date, sales,
sales - LAG(sales, 1) OVER (ORDER BY date) AS day_over_day
FROM daily_sales;
-- LAG with default value and offset
SELECT username, created,
LAG(username, 3, 'N/A') OVER (ORDER BY created) AS three_back
FROM users;
-- FIRST_VALUE / LAST_VALUE / NTH_VALUE
SELECT username, age,
FIRST_VALUE(age) OVER (ORDER BY age) AS youngest,
LAST_VALUE(age) OVER (ORDER BY age
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING) AS oldest
FROM users;NTILE & Percentiles
NTILE(n) splits ordered rows into n roughly-equal buckets — use 4 for quartiles, 100 for percentiles, 10 for deciles. CUME_DIST is the fraction of rows with a value <= the current row; PERCENT_RANK is the relative rank as a 0..1 fraction. These are the building blocks for statistical/analytical queries. Like all window functions, they don't collapse rows — wrap in an outer query to filter.
-- divide rows into N equal buckets (quartiles, percentiles)
SELECT username, age,
NTILE(4) OVER (ORDER BY age) AS quartile
FROM users;
-- CUME_DIST: cumulative distribution (0..1)
SELECT username, age,
CUME_DIST() OVER (ORDER BY age) AS cume_dist
FROM users;
-- PERCENT_RANK: rank as a fraction (0..1)
SELECT username, age,
PERCENT_RANK() OVER (ORDER BY age) AS pct_rank
FROM users;
-- NTH_VALUE: value at a specific row in the frame
SELECT username, age,
NTH_VALUE(age, 2) OVER (ORDER BY age) AS second_age
FROM users;CTE (WITH Clause)
Basic CTE
A CTE (Common Table Expression) defined with WITH is a named, scoped subquery — it improves readability and can be referenced multiple times in the same statement. SQLite typically materializes CTEs (writes them to a temp table), so performance is similar to a derived table. Use CTEs to break complex queries into stages, pre-aggregate, or avoid repeating subqueries. They're standard SQL and portable.
-- a CTE is a named temporary result set, scoped to one statement
WITH active_user_count AS (
SELECT COUNT(*) AS n FROM users WHERE active = 1
)
SELECT username,
(SELECT n FROM active_user_count) AS total_active
FROM users WHERE active = 1;
-- CTE in a join
WITH user_totals AS (
SELECT user_id, SUM(total) AS spent
FROM orders
GROUP BY user_id
)
SELECT u.username, COALESCE(t.spent, 0) AS spent
FROM users u
LEFT JOIN user_totals t ON t.user_id = u.id
ORDER BY spent DESC;
-- CTEs make complex queries readable; they are syntactic sugar
-- (SQLite often materializes them, like a temp table).Multiple CTEs
Multiple CTEs comma-separated can each reference earlier CTEs in the same WITH — ideal for staged data pipelines in a single statement. Each CTE is a logical step; reading top-to-bottom mirrors the data flow. This is far more maintainable than nested subqueries. SQLite materializes each CTE; for very large intermediate results, consider temp tables across statements instead of one giant CTE chain.
-- chain multiple CTEs, each able to reference earlier ones
WITH
active_users AS (
SELECT id, username FROM users WHERE active = 1
),
user_orders AS (
SELECT user_id, COUNT(*) AS n_orders, SUM(total) AS spent
FROM orders
GROUP BY user_id
),
labeled AS (
SELECT au.username,
COALESCE(uo.n_orders, 0) AS orders,
COALESCE(uo.spent, 0) AS spent
FROM active_users au
LEFT JOIN user_orders uo ON uo.user_id = au.id
)
SELECT * FROM labeled
WHERE orders > 0
ORDER BY spent DESC;Recursive CTE
Recursive CTEs have an anchor (base case) and a recursive member joined by UNION (dedup) or UNION ALL. They're the standard SQL way to traverse trees (org charts, category trees, threaded comments) and graphs. SQLite caps recursion depth (default 1000 via PRAGMA max_recursion_depth? — actually SQLITE_MAX_TRIGGER_DEPTH/limit). The 'generate series' trick is invaluable since SQLite lacks GENERATE_SERIES (though the series extension exists).
-- a recursive CTE references itself; perfect for hierarchies
WITH RECURSIVE subordinates AS (
-- anchor: top-level manager
SELECT id, name, manager_id, 0 AS depth
FROM employees WHERE manager_id IS NULL
UNION ALL
-- recursive: their direct reports, one level deeper
SELECT e.id, e.name, e.manager_id, s.depth + 1
FROM employees e
JOIN subordinates s ON e.manager_id = s.id
)
SELECT depth, id, name FROM subordinates ORDER BY depth, name;
-- generate a series of numbers (no native GENERATE_SERIES)
WITH RECURSIVE cnt(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM cnt WHERE n < 10
)
SELECT n FROM cnt;Hierarchical Queries
Recursive CTEs shine on self-referencing tables. The first example builds a slash-delimited path from root to each node — useful for breadcrumbs. The second finds all descendants of a node (subtree). Use UNION (not UNION ALL) if cycles are possible to prevent infinite loops; otherwise UNION ALL is faster. For very deep trees, monitor performance — recursive CTEs can be slow without proper indexes on the join column.
-- categories with parent/child self-reference
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT,
parent_id INTEGER REFERENCES categories(id)
);
-- full path from root to each node
WITH RECURSIVE cat_path(id, name, path) AS (
SELECT id, name, name
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, cp.path || ' > ' || c.name
FROM categories c
JOIN cat_path cp ON c.parent_id = cp.id
)
SELECT id, path FROM cat_path ORDER BY path;
-- find all descendants of a node
WITH RECURSIVE descendants(id) AS (
SELECT id FROM categories WHERE id = 5 -- root of subtree
UNION ALL
SELECT c.id FROM categories c
JOIN descendants d ON c.parent_id = d.id
)
SELECT * FROM categories WHERE id IN (SELECT id FROM descendants);CTE Optimization
SQLite 3.35+ supports MATERIALIZED / NOT MATERIALIZED hints to control CTE evaluation. MATERIALIZED forces a temp-table write (good for expensive CTEs referenced multiple times); NOT MATERIALIZED inlines the CTE so the optimizer can push down predicates (good for cheap CTEs). The planner usually picks well on its own; use hints only when EXPLAIN QUERY PLAN shows a bad plan. Recursive CTEs are always materialized.
-- MATERIALIZED: force SQLite to materialize (temp table)
WITH cte AS MATERIALIZED (
SELECT user_id, SUM(total) AS spent FROM orders GROUP BY user_id
)
SELECT u.username, cte.spent
FROM users u JOIN cte ON cte.user_id = u.id;
-- AS NOT MATERIALIZED: inline the CTE (may run multiple times)
WITH cte AS NOT MATERIALIZED (
SELECT COUNT(*) FROM users WHERE active = 1
)
SELECT * FROM products WHERE id < (SELECT * FROM cte);
-- choose MATERIALIZED when the CTE is expensive and used
-- multiple times; NOT MATERIALIZED when it's cheap or used
-- once and could benefit from predicate pushdown.
-- recursive CTEs are always materialized.Backup & Restore
.dump (Text Backup)
.dump exports the database as plain SQL INSERT statements — human-readable, diffable, and portable across SQLite versions and architectures. It's the most compatible backup format. Restoring replays the SQL into a fresh database. --nosys skips sqlite_sequence and other internal tables. For large databases, .dump is slow and produces huge files; use .backup (binary copy) or the Online Backup API instead.
-- dump the entire database as SQL text (portable)
sqlite3 mydb.sqlite .dump > backup.sql
-- dump a single table
sqlite3 mydb.sqlite ".dump users" > users.sql
-- dump only data (no schema)
sqlite3 mydb.sqlite ".dump" | grep '^INSERT' > data.sql
-- restore from a dump
sqlite3 newdb.sqlite < backup.sql
-- or inside the CLI:
.read backup.sql
-- dump in a specific order to satisfy FK constraints
sqlite3 mydb.sqlite ".dump --nosys" > backup.sql
-- .dump works on attached databases too
ATTACH 'archive.sqlite' AS archive;
.dump archive.backup (Binary Copy)
.backup performs a safe online binary copy using the SQLite Online Backup API — it works even while writers are active, producing a transactionally consistent snapshot. This is the recommended way to back up a running database. The output is a valid SQLite file, so restore is just a file copy. Unlike .dump, the binary format is version- and architecture-compatible but not human-readable.
-- binary copy to another file (online, safe with active writers)
sqlite3 mydb.sqlite ".backup 'backup.sqlite'"
-- inside the CLI
.backup backup.sqlite
.backup main backup.sqlite -- source 'main', target file
-- backup to an attached database
ATTACH 'backup.sqlite' AS bk;
.backup main bk;
DETACH bk;
-- .backup uses the Online Backup API internally: it copies
-- page-by-page and handles concurrent writers safely, so it's
-- the right choice for live production backups.
-- restore = just copy the file back (or use it directly)
cp backup.sqlite mydb.sqliteOnline Backup API
The Online Backup API (sqlite3_backup_*) is the gold-standard backup method: it copies a live database page-by-page, handling concurrent writes correctly and producing a consistent snapshot. Python's sqlite3.Connection.backup() and Node's better-sqlite3 .backup() wrap it. It works in WAL mode. Raw file copy can produce a corrupt backup if a write is in progress — only use file copy on a stopped database or with file-level snapshots.
-- the sqlite3 CLI's .backup uses this C API; in Python:
import sqlite3
src = sqlite3.connect("mydb.sqlite")
dst = sqlite3.connect("backup.sqlite")
src.backup(dst)
dst.close()
src.close()
-- with progress callback
def progress(remaining, total):
print(f"{total - remaining}/{total} pages copied")
src.backup(dst, pages=progress)
-- this is the safest backup method: it's online (doesn't block
-- writers), transactionally consistent, and works on WAL-mode
-- databases. Prefer it over raw file copy for production.
-- Node.js (better-sqlite3):
const backup = db.backup('backup.sqlite');
while (!backup.completed) backup.step(-1);
backup.delete();Attaching Databases
ATTACH DATABASE brings up to 10 database files into one connection as named schemas (main, temp, plus attached). You can query, join, and copy data across them with schema-qualified names (main.users, archive.orders). This is the basis for archival (move old rows to an archive file), sharding, and merge workflows. DETACH closes the schema but leaves the file intact. Cross-database transactions are atomic in WAL mode.
-- work with multiple DB files in one connection
ATTACH DATABASE 'archive.sqlite' AS archive;
ATTACH DATABASE 'lookup.sqlite' AS lookup;
-- query across databases
SELECT u.username, a.note
FROM main.users u
JOIN archive.old_users a ON u.id = a.id;
-- copy a table between databases
CREATE TABLE archive.users_copy AS
SELECT * FROM main.users;
-- move data: insert into one DB, delete from another
INSERT INTO archive.orders SELECT * FROM main.orders WHERE created < '2023-01-01';
DELETE FROM main.orders WHERE created < '2023-01-01';
-- list attached databases
.databases
-- detach (does NOT delete the file)
DETACH DATABASE archive;
-- limit: 10 attached databases (compile-time SQLITE_MAX_ATTACHED).Recovery & Corruption
integrity_check detects structural corruption; .recover (3.29+) is the best tool to salvage data from a corrupt file — it extracts readable rows and skips bad pages, producing SQL you can pipe into a fresh database. If .recover fails, fall back to .dump and accept partial loss. Most corruption comes from unsafe file operations (copying a live DB, network filesystems, power loss with synchronous=OFF). Always backup before recovery attempts.
-- check integrity
PRAGMA integrity_check;
PRAGMA quick_check;
-- recover from a corrupt database (best effort)
sqlite3 corrupt.sqlite ".recover" > recovered.sql
sqlite3 clean.sqlite < recovered.sql
-- .recover extracts everything readable, skipping corrupt pages
-- if .recover fails, try dumping what you can
sqlite3 corrupt.sqlite ".dump" > partial.sql 2>errors.txt
sqlite3 clean.sqlite < partial.sql
-- point-in-time from a backup file
cp nightly_backup.sqlite mydb.sqlite
-- after a crash, WAL is replayed automatically on next open;
-- if stuck, checkpoint manually:
sqlite3 mydb.sqlite "PRAGMA wal_checkpoint(TRUNCATE);"
-- prevent corruption: never overwrite a live DB file,
-- never share a DB on a network filesystem without WAL off.Import & Export
CSV Import
.import reads CSV into a table; if the table doesn't exist, SQLite creates it with all-TEXT columns (often not what you want — create the table first with proper types). --skip N skips header rows. .mode csv configures the parser to handle quoted fields with embedded commas and newlines. For large imports, wrap in BEGIN/COMMIT and PRAGMA synchronous=OFF (temporarily) for a 10x+ speedup. Disable indexes during bulk loads and recreate after.
-- import a CSV file into a table
.mode csv
.import users.csv users
-- ^ creates 'users' table with text columns named from the header row
-- import into an EXISTING table (column order must match)
.mode csv
.import --skip 1 users.csv users -- --skip 1 to skip header
-- recommended: create the table first with proper types
CREATE TABLE users (id INTEGER, name TEXT, email TEXT);
.mode csv
.import --skip 1 users.csv users
-- from the shell, one-shot
sqlite3 mydb.sqlite ".mode csv" ".import users.csv users"
-- handle quotes/escapes
.mode csv
.separator ,
.import data.csv mytableCSV Export
.mode csv with .headers on produces a standard CSV with a header row — pipe to a file with .output. The shell flags -header -csv plus output redirection do it in one line without entering the CLI. For TSV use .mode list with .separator "\t". For large exports, this is stream-based and memory-efficient. To export multiple tables, change .output between queries.
-- export a query to CSV
.mode csv
.headers on
.output users.csv
SELECT id, username, email FROM users;
.output stdout
-- from the shell, one-shot
sqlite3 mydb.sqlite -header -csv \
"SELECT id, username, email FROM users" > users.csv
-- export all rows of a table
.mode csv
.headers on
.output products.csv
SELECT * FROM products;
.output stdout
-- custom separator
.mode list
.separator "|"
.output users.txt
SELECT id, username FROM users;
.output stdoutJSON Export
.mode json emits one JSON object per row (no wrapping array). For a proper JSON array, use json_group_array(json_object(...)) — this gives you full control over field names and nesting, and works for nested arrays via correlated subqueries. This is a powerful way to build JSON APIs directly from SQL without an ORM. For large result sets, stream .mode json line-by-line rather than building one giant array in memory.
-- export a query as JSON (one JSON object per row)
.mode json
.output users.json
SELECT id, username, email FROM users;
.output stdout
-- build a single JSON array with json_group_array
SELECT json_group_array(json_object(
'id', id, 'username', username, 'email', email
)) AS users_json
FROM users;
-- [{"id":1,"username":"alice","email":"[email protected]"},...]
-- nested JSON (orders with their items)
SELECT json_object(
'order_id', o.id,
'total', o.total,
'items', (SELECT json_group_array(json_object('name', i.name, 'qty', i.qty))
FROM order_items i WHERE i.order_id = o.id)
) AS order_json
FROM orders o;Importing JSON
SQLite has no .import for JSON — load the file as TEXT (via app code or .read with INSERTs) then parse with json_each/json_extract. json_each(array) expands an array into rows for set-based insertion. For large JSON files, stream parse in your application (don't load the whole file into memory). Validate with json_valid() during load and use a CHECK constraint on JSON columns for ongoing integrity.
-- load a JSON file into a text column, then parse
CREATE TABLE raw (data TEXT);
-- (no built-in .import for JSON; load via CLI or app)
-- once loaded, parse with json_each (array) or json_extract (object)
SELECT json_extract(data, '$.name') AS name,
json_extract(data, '$.age') AS age
FROM raw;
-- expand a JSON array into rows
INSERT INTO users (username, email)
SELECT json_extract(value, '$.username'),
json_extract(value, '$.email')
FROM raw, json_each(raw.data);
-- import via Python
import sqlite3, json
db = sqlite3.connect("mydb.sqlite")
with open("users.json") as f:
for row in json.load(f):
db.execute("INSERT INTO users(username,email) VALUES(?,?)",
(row["username"], row["email"]))
db.commit()Excel & Other Formats
For Excel, export CSV (Excel opens it directly); .mode excel (3.36+) adds a BOM so UTF-8 renders correctly. .mode markdown produces pasteable table markup for docs. .mode insert generates INSERT statements — great for seeding test databases or migrating data between schemas. .mode quote quotes every value, useful when data contains commas/newlines/quotes that might confuse other tools. .mode table/box draw ASCII tables for terminals.
-- Excel: export CSV (Excel opens it) or use .mode excel
.mode csv
.headers on
.output report.csv
SELECT * FROM monthly_report;
.output stdout
-- .mode excel (3.36+): CSV with BOM, friendly to Excel
.mode excel
.output report.csv
SELECT * FROM monthly_report;
.output stdout
-- .mode markdown: GitHub-flavored markdown tables
.mode markdown
SELECT id, username, email FROM users LIMIT 5;
-- .mode insert: generate INSERT statements
.mode insert users
.output seed.sql
SELECT id, username, email FROM users;
.output stdout
-- produces: INSERT INTO "users" VALUES(1,'alice','[email protected]');
-- .mode quote: CSV with all values quoted
.mode quote
.output safe.csv
SELECT * FROM users;
.output stdoutApplication Integration
Python (sqlite3)
Python's sqlite3 is built into the standard library. ALWAYS use ? placeholders (or :name) — never f-strings or % — to prevent SQL injection and handle quoting correctly. executemany is much faster than looping execute for bulk inserts. Set row_factory = sqlite3.Row for column-name access. Use context managers (with conn:) for automatic commit/rollback. For concurrency, use one connection per thread (sqlite3 forbids cross-thread sharing by default).
import sqlite3
# connect (creates the file if missing)
conn = sqlite3.connect("mydb.sqlite")
conn.row_factory = sqlite3.Row # dict-like rows
cur = conn.cursor()
# parameterized query (NEVER use string formatting!)
cur.execute(
"INSERT INTO users (username, email) VALUES (?, ?)",
("alice", "[email protected]"),
)
conn.commit()
# query with parameters
cur.execute("SELECT id, username FROM users WHERE active = ?", (1,))
for row in cur:
print(row["id"], row["username"])
# named parameters
cur.execute(
"SELECT * FROM users WHERE age >= :min_age",
{"min_age": 18},
)
# bulk insert (fast)
cur.executemany(
"INSERT INTO users (username, email) VALUES (?, ?)",
[("bob", "[email protected]"), ("carol", "[email protected]")],
)
conn.commit()
conn.close()Node.js (better-sqlite3)
better-sqlite3 is synchronous (no callback/Promise boilerplate) and the fastest Node.js SQLite driver. Prepared statements are reused — prepare once, run many times. .transaction(fn) wraps the function in BEGIN/COMMIT (or ROLLBACK on throw), and is dramatically faster than auto-commit per statement. Use ? placeholders to avoid SQL injection. Enable WAL for concurrency. For Promise-based APIs, wrap calls or use sqlite/sqlite3 instead.
const Database = require("better-sqlite3");
const db = new Database("mydb.sqlite");
db.pragma("journal_mode = WAL");
// prepared statement (synchronous, fast)
const insert = db.prepare(
"INSERT INTO users (username, email) VALUES (?, ?)"
);
const info = insert.run("alice", "[email protected]");
console.log(info.lastInsertRowid);
// query multiple rows
const select = db.prepare("SELECT id, username FROM users WHERE active = ?");
const rows = select.all(1); // array of objects
// query one row
const one = select.get(1);
// transaction (atomic, fast)
const insertMany = db.transaction((users) => {
for (const u of users) insert.run(u.username, u.email);
});
insertMany([{username: "bob"}, {username: "carol"}]);
db.close();Connection URI & Modes
URI filenames (file:...) unlock read-only, shared-cache, and in-memory modes — pass uri=True in Python or the file: string to Node drivers. :memory: is per-connection by default; file:name?mode=memory&cache=shared creates a named in-memory DB shared across connections in the same process (great for tests). Set a busy_timeout so writers briefly waiting on locks don't immediately error. Always set PRAGMA foreign_keys = ON per connection.
# open read-only
sqlite3.connect("file:mydb.sqlite?mode=ro", uri=True)
# open in-memory (vanishes on close)
sqlite3.connect(":memory:")
# or via URI:
sqlite3.connect("file::memory:", uri=True)
# shared in-memory database (visible across connections in same process)
sqlite3.connect("file:memdb1?mode=memory&cache=shared", uri=True)
# with a busy timeout (wait instead of erroring on locks)
conn = sqlite3.connect("mydb.sqlite", timeout=30)
# Node.js: same URI form
const db = new Database("file:mydb.sqlite?mode=ro", { readonly: true });
# Node.js in-memory
const mem = new Database(":memory:");
# Python: per-connection pragmas
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")Transactions & Concurrency
Transactions group statements atomically — all-or-nothing. SQLite allows a single writer at a time (database-level write lock), but WAL mode allows concurrent readers. In Python, the default isolation_level wraps DML in implicit transactions (commit needed). For explicit control set isolation_level = None and issue BEGIN/COMMIT yourself. Use a busy_timeout (PRAGMA busy_timeout or connection timeout) so writers briefly waiting on a lock wait rather than immediately throwing SQLITE_BUSY.
# Python: explicit transaction
conn = sqlite3.connect("mydb.sqlite")
conn.isolation_level = None # autocommit off -> manual BEGIN
cur = conn.cursor()
cur.execute("BEGIN")
try:
cur.execute("UPDATE accounts SET bal = bal - 100 WHERE id = 1")
cur.execute("UPDATE accounts SET bal = bal + 100 WHERE id = 2")
cur.execute("COMMIT")
except Exception:
cur.execute("ROLLBACK")
raise
// Node.js (better-sqlite3) transaction
const transfer = db.transaction((from, to, amt) => {
db.prepare("UPDATE accounts SET bal = bal - ? WHERE id = ?").run(amt, from);
db.prepare("UPDATE accounts SET bal = bal + ? WHERE id = ?").run(amt, to);
});
transfer(1, 2, 100);
-- SQLite allows ONE writer at a time; many readers in WAL mode.
-- Use a busy_timeout to wait for locks instead of failing fast.Prepared Statements & Safety
Parameterized queries (? placeholders) are the single most important security practice — they prevent SQL injection and handle type conversion/escaping correctly. NEVER interpolate user input into SQL strings. Placeholders work for values only; identifiers (table/column names) can't be parameterized — validate them against a whitelist. For LIKE, escape % and _ in user input so users can't inject wildcards. This applies in every language and driver.
-- SQL injection vulnerability (NEVER do this):
-- app: "SELECT * FROM users WHERE name = '" + user_input + "'"
-- user_input = "'; DROP TABLE users; --"
-- result: SELECT * FROM users WHERE name = ''; DROP TABLE users; --'
-- SAFE: parameterized query (placeholders are NOT string substitution)
-- Python:
cur.execute("SELECT * FROM users WHERE name = ?", (user_input,))
-- Node.js:
stmt.get(user_input);
-- identifiers (table/column names) CANNOT be parameterized;
-- validate against a whitelist:
allowed_tables = {"users", "orders", "products"}
if table not in allowed_tables:
raise ValueError("bad table")
cur.execute(f"SELECT * FROM {table} WHERE id = ?", (id_,))
-- LIKE with user input: escape wildcards
cur.execute(
"SELECT * FROM users WHERE name LIKE ? ESCAPE '\'",
(user_input.replace("\", "\\").replace("%", "\%").replace("_", "\_") + "%",)
)Performance Optimization
EXPLAIN QUERY PLAN
EXPLAIN QUERY PLAN is the #1 tuning tool. 'USING INDEX' = good; 'SCAN' (full table scan) = usually bad on large tables. For joins, check that the join columns are indexed and that the planner isn't scanning the bigger table. EXPLAIN (without QUERY PLAN) shows the VDBE bytecode — rarely needed but useful for deep debugging. The experimental EXPERT command can suggest indexes (3.36+), or use the .expert CLI dot-command.
-- see how SQLite executes a query (essential for tuning)
EXPLAIN QUERY PLAN
SELECT * FROM users WHERE email = '[email protected]';
-- good: uses an index
-- SEARCH users USING INDEX idx_users_email (email=?)
-- bad: full table scan
-- SCAN users
-- join plan
EXPLAIN QUERY PLAN
SELECT u.username, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.active = 1;
-- automated index recommendation (3.36+, experimental)
SELECT * FROM users WHERE email = 'x';
-- then:
EXPLAIN QUERY PLAN SELECT * FROM sqlite_stat1;
-- lower-level: EXPLAIN (bytecode)
EXPLAIN SELECT * FROM users WHERE id = 1;VACUUM & ANALYZE
VACUUM rebuilds the file, reclaiming space from deletes and defragmenting pages — run it after large deletes or periodically for write-heavy databases. VACUUM INTO (3.27+) writes a clean copy without locking the source for long. ANALYZE updates sqlite_stat1 statistics the query planner uses to choose indexes — run it after bulk loads or schema/data changes. auto_vacuum=INCREMENTAL reclaims space incrementally without a full VACUUM.
-- VACUUM: rebuild the database file, reclaiming free space
VACUUM;
-- after large deletes, this shrinks the file and defrags pages
-- VACUUM INTO: write a defragmented copy to a new file (3.27+)
VACUUM INTO 'clean.sqlite';
-- incremental VACUUM (only with auto_vacuum enabled)
PRAGMA auto_vacuum = INCREMENTAL;
PRAGMA incremental_vacuum(100); -- free up to 100 pages
-- ANALYZE: update planner statistics for better query plans
ANALYZE; -- all tables
ANALYZE users; -- one table
ANALYZE users(idx_users_email); -- one index
-- run ANALYZE after bulk loads or major data changesBulk Loading
For bulk loads: wrap in a single transaction (the single biggest speedup — autocommit per row is catastrophically slow), temporarily disable synchronous and journaling, drop and recreate indexes, and consider PRAGMA cache_size increase. The .import dot-command is faster than INSERT statements. For truly huge loads, consider the .backup of a pre-built database. Always restore safety PRAGMAs after loading production data.
-- fastest bulk load into a fresh table
PRAGMA synchronous = OFF; -- risk: power-loss corruption (use only for one-off loads)
PRAGMA journal_mode = MEMORY;
PRAGMA temp_store = MEMORY;
BEGIN;
CREATE TABLE big (id INTEGER, data TEXT);
.import data.csv big
COMMIT;
-- then turn safety back on
PRAGMA synchronous = FULL;
PRAGMA journal_mode = WAL;
-- faster still: drop indexes, load, recreate
DROP INDEX idx_big_data;
-- ... load ...
CREATE INDEX idx_big_data ON big(data);
-- use a transaction (10-100x faster than autocommit per row)
BEGIN;
INSERT INTO big VALUES (1, 'a');
-- ... thousands of inserts ...
COMMIT;FTS5 (Full-Text Search)
FTS5 is SQLite's full-text search engine — it indexes text for fast MATCH queries, far faster than LIKE '%term%' which scans every row. It supports boolean (AND/OR/NOT), prefix (term*), phrase ("..."), and ranking by relevance. The external-content pattern (content='posts') avoids duplicating data. Keep the FTS index in sync with triggers (INSERT/UPDATE/DELETE on the base table). FTS5 is ideal for search-as-you-type, autocomplete, and document search.
-- create a full-text search virtual table
CREATE VIRTUAL TABLE posts_fts USING fts5(
title, body,
content='posts', content_rowid='id'
);
-- populate it (keep in sync with triggers or rebuild)
INSERT INTO posts_fts(rowid, title, body)
SELECT id, title, body FROM posts;
-- search with MATCH (much faster than LIKE '%term%')
SELECT p.* FROM posts p
JOIN posts_fts f ON f.rowid = p.id
WHERE posts_fts MATCH 'sqlite python';
-- ranking by relevance
SELECT p.title, rank
FROM posts p
JOIN posts_fts f ON f.rowid = p.id
WHERE posts_fts MATCH 'sqlite OR python'
ORDER BY rank;
-- prefix and phrase queries
SELECT * FROM posts_fts WHERE posts_fts MATCH 'sql*'; -- prefix
SELECT * FROM posts_fts WHERE posts_fts MATCH '"full text"'; -- exact phraseCommon Pitfalls & Tuning
Most SQLite performance issues come from a handful of causes: missing indexes on WHERE/JOIN/ORDER BY columns, missing transactions (autocommit per row is the #1 cause of slow bulk inserts), foreign keys silently off, and SELECT * on wide tables. Enable WAL and foreign keys in every connection setup. Batch large DELETEs to avoid long write locks and huge temp spaces. Run ANALYZE after major data changes so the planner picks good plans.
-- 1. Enable foreign keys (OFF by default!)
PRAGMA foreign_keys = ON;
-- 2. Use WAL for concurrency
PRAGMA journal_mode = WAL;
-- 3. Index foreign keys AND join columns
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- 4. Avoid SELECT * (more I/O, breaks on schema change)
SELECT id, username FROM users;
-- 5. Use transactions for multi-statement writes
BEGIN;
-- ... several inserts/updates ...
COMMIT;
-- 6. Don't index every column (slows writes)
-- index selectively on WHERE/JOIN/ORDER BY columns.
-- 7. Use LIMIT on exploratory queries
SELECT * FROM big_table LIMIT 10;
-- 8. Batch UPDATEs/DELETEs to avoid long locks
DELETE FROM logs WHERE id IN (
SELECT id FROM logs WHERE ts < '2023-01-01' LIMIT 10000
);
-- 9. Prefer EXISTS over IN for large subquery results
-- 10. Run ANALYZE after big data changes.関連する SQLite スニペット
Copy-paste ready code for common tasks.
Create Table
Define tables with constraints and autoincrement keys.
Insert & Query
Insert rows and query with parameterized statements.
JOINs & Aggregates
Combine tables and aggregate grouped rows.
Indexes & EXPLAIN
Create indexes and inspect query plans.
Transactions & Savepoints
Wrap statements in atomic units with savepoints.
PRAGMA Statements
Configure SQLite behavior and inspect the database.
ATTACH Database
Query across multiple database files in one connection.
Export & Import
Dump databases to SQL and restore from dumps.
Was this helpful?