Cheat Sheet · v3.45 · 2024

SQLite.
The quiet database.

Read the brief Jump to commands
1T+
Deployments worldwide
0
Servers required
~750KB
Library footprint
1
File on disk
01 / Introduction

Not a database server.
A database file.

SQLite is a C library that reads and writes a self-contained SQL database directly from a single file on disk. No daemon, no port, no authentication handshake. It is the most widely deployed database engine in the world — present in every Android phone, every iOS device, every Chrome and Firefox tab, every macOS install. You almost certainly have a thousand SQLite databases running inside ten feet of you right now.

02 Data Types 03 Schema 04 CRUD 05 Querying 06 Joins 07 Aggregates 08 Subqueries 09 Indexes 10 Views 11 Triggers 12 PRAGMA 13 Functions 14 Tips

What makes it different

Traditional databases like PostgreSQL or MySQL run as separate server processes. Your application talks to them over a network socket. SQLite has no server. The library is linked directly into your application, and the database is a regular file on the filesystem.

This changes everything about how you use it. No connection pool tuning. No network latency. No authentication. Just sqlite3_open() and you're talking to disk.

LICENSE · Public domain
WRITTEN IN · C
FIRST RELEASE · 2000
AUTHOR · D. Richard Hipp
Open a database from the shellCLI
If the file does not exist, SQLite creates it. No separate server step, no init command.
Shell$ sqlite3 my_database.db
SQLite version 3.45.0 2024-01-15
Enter ".help" for usage hints.
sqlite>
In-memory databaseTESTING
Pass :memory: as the filename. The database lives in RAM and is discarded when the connection closes — perfect for tests and scratch work.
Shell$ sqlite3 :memory:
sqlite> CREATE TABLE t(x);
sqlite> INSERT INTO t VALUES (1),(2);
sqlite> SELECT sum(x) FROM t;
3
Attach a second databaseMULTI-DB
A single connection can read and write across multiple files. Schemas live in separate namespaces — query them with the db.table syntax.
ATTACH DATABASE 'archive.db' AS archive;
SELECT * FROM archive.orders WHERE year = 2023;
DETACH DATABASE archive;
Inspect the schemaDOT-COMMAND
Dot-commands are SQLite CLI shortcuts — not SQL. They don't end with a semicolon and only work inside the sqlite3 shell, not via the C API.
.tables                -- list all tables
.schema users          -- show CREATE statement
.indexes               -- list all indexes
.dump                  -- export entire DB as SQL
.read seed.sql         -- execute a SQL file
.mode column           -- pretty column output
.headers on            -- show column names
02 / Storage Classes

Five storage classes.
Dynamic per row.

SQLite uses dynamic typing — the type of a value is stored alongside the value, not enforced by the column. A column declared INTEGER can hold text if you ask it to. This is a feature, not a bug: it lets SQLite store schemas from any other database engine without alteration.

INTEGER
Signed integer, 1–8 bytes depending on magnitude. Stores up to 2⁶³-1.
42 · -7 · 9223372036854775807
REAL
8-byte IEEE 754 floating point. Used for any value with a decimal point.
3.14 · -0.001 · 6.022e23
TEXT
Character string, stored in database encoding (UTF-8, UTF-16BE, or UTF-16LE). No length limit.
'hello' · "café" · x'4e61dc'
BLOB
Raw byte array, stored exactly as input. Used for images, serialized data, anything binary.
X'CAFE' · readfile('img.png')
NULL
The absence of a value. Distinct from 0, empty string, or any other value.
NULL · IS NULL · IS NOT NULL
NUMERIC
Affinity, not a class. SQLite converts to INTEGER or REAL when possible, keeps TEXT when not.
'42' → 42 · '3.14' → 3.14

Type affinity

When you declare a column as VARCHAR(255), SQLite doesn't enforce length. It assigns the column an affinity — a hint about how to coerce values. The five affinities are TEXT, NUMERIC, INTEGER, REAL, and BLOB (none).

This is why SQLite can read a PostgreSQL dump without schema changes: VARCHAR(100), DECIMAL(10,2), BOOLEAN all map cleanly to one of the five affinities.

Column affinity rulesREFERENCE
SQLite applies affinity based on the declared type string. The rules are simple but worth knowing.
-- Contains "INT"   → INTEGER affinity
CREATE TABLE t1(x INT, y BIGINT, z TINYINT);

-- Contains "CHAR","CLOB","TEXT" → TEXT affinity
CREATE TABLE t2(a VARCHAR(100), b TEXT, c CLOB);

-- Contains "BLOB" or no type → BLOB (no affinity)
CREATE TABLE t3(data BLOB, anything);

-- Contains "REAL","FLOA","DOUB" → REAL affinity
CREATE TABLE t4(price REAL, rate DOUBLE);

-- Anything else → NUMERIC affinity
CREATE TABLE t5(amount DECIMAL(10,2), flag BOOLEAN);
Boolean valuesPATTERN
SQLite has no native BOOLEAN. Use INTEGER 0/1. The library treats 0 as false and any non-zero integer as true in boolean contexts.
CREATE TABLE users(
  id INTEGER PRIMARY KEY,
  is_active INTEGER DEFAULT 1,  -- 0=false, 1=true
  email_verified INTEGER DEFAULT 0
);

SELECT * FROM users WHERE is_active;      -- truthy
SELECT * FROM users WHERE NOT email_verified; -- falsy
03 / Schema

Building the container.

Schema statements create the structure — tables, columns, constraints, indexes. They are infrequent operations, but they define every other query you'll write. Get them right.

Tables, columns, constraints

A table is a named collection of rows, each row having the same set of columns. Columns have names, types, and optional constraints: NOT NULL, UNIQUE, DEFAULT, CHECK, PRIMARY KEY, FOREIGN KEY.

SQLite stores the schema in a special table called sqlite_schema (also accessible as sqlite_master). Querying it shows the CREATE statements for everything in the database.

CONSTRAINTS · 6 types
ALTER SUPPORTED · RENAME, ADD COLUMN
NOT SUPPORTED · DROP COLUMN (pre-3.35)
CREATE TABLE with constraintsCORE
A realistic table with primary key, foreign key, defaults, unique, and check constraints.
CREATE TABLE orders (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id    INTEGER NOT NULL,
  total      REAL    NOT NULL CHECK (total >= 0),
  status     TEXT    NOT NULL DEFAULT 'pending'
                      CHECK (status IN ('pending','paid','shipped')),
  created_at TEXT    NOT NULL DEFAULT (datetime('now')),
  UNIQUE(user_id, created_at),
  FOREIGN KEY (user_id) REFERENCES users(id)
    ON DELETE CASCADE ON UPDATE CASCADE
);
Without ROWID tablesADVANCED
By default every SQLite table has a hidden rowid column. Adding WITHOUT ROWID removes it — useful for tables where the primary key is the only lookup, saving disk and reads.
CREATE TABLE kv_store (
  key   TEXT PRIMARY KEY,
  value BLOB NOT NULL
) WITHOUT ROWID;

-- Lookup by primary key is now a single B-tree search
-- instead of two (rowid → PK → data).
ALTER TABLEMIGRATION
SQLite's ALTER is intentionally minimal. To drop a column or change a type, the standard pattern is: rename, create new, copy, drop, rename.
-- Supported operations:
ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0;
ALTER TABLE users RENAME COLUMN email TO email_address;
ALTER TABLE users RENAME TO accounts;
ALTER TABLE users DROP COLUMN legacy_field;  -- 3.35.0+

-- To change a column type, recreate:
BEGIN;
ALTER TABLE users RENAME TO users_old;
CREATE TABLE users ( /* new definition */ );
INSERT INTO users SELECT * FROM users_old;
DROP TABLE users_old;
COMMIT;
DROP TABLEDESTRUCTIVE
Removes the table, its schema, all data, and any indexes on it. Foreign keys pointing to it are not automatically dropped — be careful.
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS orders RESTRICT;  -- error if FK references
DROP TABLE IF EXISTS orders CASCADE;  -- drop dependent FKs
04 / CRUD

Create, Read,
Update, Delete.

The four operations you'll perform a billion times. SQLite supports the standard SQL syntax with a few thoughtful additions — most notably INSERT ... ON CONFLICT ... DO UPDATE, the SQLite-native upsert.

The four verbs

INSERT adds rows. SELECT reads them. UPDATE modifies existing rows in place. DELETE removes them. All four can be parameterized with ? placeholders to prevent SQL injection — use them always, even for "internal" queries.

Every mutation runs inside an implicit transaction. If you batch many writes, wrap them in BEGIN ... COMMIT — a thousand inserts in one transaction is roughly 50× faster than a thousand separate inserts.

PLACEHOLDER · ? or :name
BATCH SPEEDUP · ~50×
RETURNING · 3.35+
INSERT — single rowCREATE
INSERT INTO users (name, email, age)
VALUES ('Ada Lovelace', 'ada@example.com', 36);
INSERT — multiple rowsBATCH
One statement, many rows. Far faster than looping inserts in application code.
INSERT INTO users (name, email)
VALUES
  ('Grace Hopper',   'grace@example.com'),
  ('Alan Turing',   'alan@example.com'),
  ('Margaret Hamilton', 'margaret@example.com');
INSERT — with named parametersSAFE
Bind values by name in your application. Order-independent, self-documenting, immune to injection.
INSERT INTO users (name, email, age)
VALUES (:name, :email, :age);
-- bind :name='Linus', :email='linus@linux.org', :age=54
UPSERT — INSERT ... ON CONFLICTPOWER
If the insert would violate a UNIQUE or PRIMARY KEY constraint, run the DO UPDATE branch instead. The special excluded table refers to the row that was being inserted.
INSERT INTO users (id, name, login_count)
VALUES (1, 'Ada', 1)
ON CONFLICT(id) DO UPDATE SET
  name        = excluded.name,
  login_count = users.login_count + 1
WHERE users.name != excluded.name;
SELECT — basicREAD
SELECT id, name, email FROM users WHERE age >= 18;
SELECT * FROM users;                          -- avoid * in production
SELECT DISTINCT country FROM users ORDER BY country;
UPDATE — with RETURNINGUPDATE
RETURNING gives you back the affected rows without a separate SELECT. Available since SQLite 3.35 (2021).
UPDATE users
SET last_login = datetime('now'),
    login_count = login_count + 1
WHERE id = 42
RETURNING id, name, login_count;
DELETE — with RETURNINGDELETE
DELETE removes entire rows. There is no undo — wrap in a transaction if unsure. WHERE is mandatory in any real code; without it, every row goes.
DELETE FROM orders
WHERE status = 'cancelled' AND created_at < datetime('now', '-90 days')
RETURNING id, user_id;
TransactionsDURABILITY
A transaction is atomic — all writes succeed or none do. Without an explicit BEGIN, every statement gets its own transaction and its own disk sync. That's slow.
BEGIN TRANSACTION;
  INSERT INTO accounts (id, balance) VALUES (1, 100);
  UPDATE accounts SET balance = balance - 50 WHERE id = 1;
  INSERT INTO ledger (account_id, delta) VALUES (1, -50);
COMMIT;   -- or ROLLBACK to undo all of the above
05 / Querying

Filtering, sorting,
paging rows.

SELECT is the workhorse. The clauses execute in a specific order — FROM, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT — and understanding that order resolves most "why doesn't this work" confusion.

Clause execution order

SQL reads top-to-bottom but executes inside-out. The FROM/JOIN runs first, then WHERE filters, then GROUP BY collapses, then HAVING filters the groups, then SELECT picks columns, then ORDER BY sorts, then LIMIT cuts.

This is why you can't use a column alias defined in SELECT inside WHERE — the alias doesn't exist yet when WHERE runs. You can use it in ORDER BY, which runs later.

CLAUSE ORDER · 6 stages
NULL SORT · NULLS FIRST default ASC
WHERE — comparison operatorsFILTER
SELECT * FROM products
WHERE price < 100
  AND stock > 0
  AND category != 'discontinued'
  AND sku <> 'N/A';            -- <> is the same as !=
WHERE — IN, BETWEEN, LIKE, GLOBFILTER
GLOB is SQLite's case-sensitive wildcard matcher using Unix glob syntax (* and ?). LIKE is case-insensitive for ASCII and uses % and _.
SELECT * FROM users
WHERE country IN ('JP', 'KR', 'TW')
  AND age BETWEEN 25 AND 45      -- inclusive
  AND name LIKE 'A%'            -- starts with A
  AND email GLOB '*@example.*';   -- case-sensitive
NULL handlingPITFALL
NULL breaks intuition. WHERE x = NULL matches nothing — use IS NULL. NULL compared to anything, including itself, yields NULL (treated as false).
SELECT * FROM users WHERE deleted_at IS NULL;
SELECT * FROM users WHERE deleted_at IS NOT NULL;
SELECT COALESCE(nickname, name, 'anonymous') AS display;
SELECT NULLIF(status, 'draft') FROM posts;  -- NULL if 'draft'
ORDER BY — sortingSORT
SELECT * FROM products
ORDER BY
  category ASC,          -- ascending (default)
  price    DESC,         -- highest first
  name     ASC NULLS LAST;  -- NULLs go last

-- Order by expression
SELECT * FROM orders ORDER BY ABS(total - 100) LIMIT 5;
LIMIT & OFFSET — paginationPAGE
OFFSET skips rows — fine for small datasets, slow for large. For high-offset pages, use keyset pagination: WHERE id > last_seen_id ORDER BY id LIMIT 20.
-- Page 3 (rows 41-60)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 40;

-- Keyset pagination (faster on large tables)
SELECT * FROM orders
WHERE id > :last_seen_id
ORDER BY id LIMIT 20;
CASE — conditional expressionLOGIC
SELECT
  name,
  CASE
    WHEN age < 18  THEN 'minor'
    WHEN age < 65  THEN 'adult'
    ELSE             'senior'
  END AS category,
  CASE WHEN status = 'active' THEN 1 ELSE 0 END AS is_active
FROM users;
DISTINCT — unique rowsDEDUPE
SELECT DISTINCT country FROM users ORDER BY country;

-- DISTINCT on multiple columns
SELECT DISTINCT country, city FROM users;

-- DISTINCT inside aggregate
SELECT COUNT(DISTINCT country) FROM users;
06 / Joins

Two tables,
one result.

JOIN combines rows from two tables based on a condition. SQLite supports INNER, LEFT, CROSS, and (with a workaround) RIGHT and FULL OUTER. The mental model: for each row in the left table, find matching rows in the right table, emit a row for each match.

The four joins

INNER JOIN — keep only rows that match in both tables.

LEFT JOIN — keep every row from the left table; fill NULLs on the right when no match.

CROSS JOIN — Cartesian product. Every left row paired with every right row. Use sparingly.

RIGHT / FULL OUTER — supported since SQLite 3.39 (2022). Older versions emulate RIGHT JOIN by swapping tables in a LEFT JOIN.

MAX TABLES PER QUERY · ~500
JOIN ORDER · optimizer picks
INNER JOIN — the common caseJOIN
Returns only rows where the ON condition is true in both tables. The default JOIN type.
SELECT
  u.name,
  o.id    AS order_id,
  o.total
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid'
ORDER BY o.id DESC;
LEFT JOIN — keep all left rowsJOIN
Every user appears, even those without orders. Their order columns will be NULL. Use this to find "users who have never ordered."
-- Users with no orders
SELECT u.name
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL;
Multiple joinsJOIN
SELECT
  u.name,
  o.id     AS order_id,
  p.name   AS product_name,
  oi.qty
FROM users        u
JOIN  orders       o  ON o.user_id  = u.id
JOIN  order_items  oi ON oi.order_id = o.id
JOIN  products     p  ON p.id        = oi.product_id
WHERE u.country = 'JP';
Self joinPATTERN
Join a table to itself by giving it two aliases. Classic for hierarchical data — employees and managers, categories and parents.
SELECT
  e.name  AS employee,
  m.name  AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
CROSS JOIN — Cartesian productJOIN
Every row of A paired with every row of B. Useful for generating combinations, but dangerous on large tables — N×M rows.
-- All possible size × color combinations
SELECT s.name AS size, c.name AS color
FROM sizes s
CROSS JOIN colors c;
FULL OUTER JOINJOIN
Returns all rows from both tables; NULLs fill in where there's no match on either side. Available since SQLite 3.39.
SELECT u.name, o.id AS order_id
FROM users  u
FULL OUTER JOIN orders o ON o.user_id = u.id;
07 / Aggregates

Many rows,
one number.

Aggregate functions collapse groups of rows into single values. Pair them with GROUP BY to compute per-category stats — counts, sums, averages, minimums, maximums, and concatenated strings.

The standard aggregates

COUNT(*) COUNT(col) SUM TOTAL AVG MIN MAX GROUP_CONCAT

COUNT(*) counts every row including NULLs. COUNT(column) counts only non-NULL values. The distinction matters when joining — a LEFT JOIN with no match produces NULL, and COUNT(column) will skip it.

NULL BEHAVIOR · skipped (except COUNT*)
DISTINCT · supported
GROUP BY — basicAGG
SELECT
  country,
  COUNT(*)         AS user_count,
  AVG(age)         AS avg_age,
  MIN(created_at)  AS first_user,
  MAX(created_at)  AS latest_user
FROM users
GROUP BY country
ORDER BY user_count DESC;
HAVING — filter groupsAGG
WHERE filters rows before grouping; HAVING filters groups after. Use HAVING when the condition involves an aggregate.
SELECT user_id, COUNT(*) AS order_count, SUM(total) AS spent
FROM orders
WHERE status = 'paid'
GROUP BY user_id
HAVING COUNT(*) >= 5 AND SUM(total) > 1000
ORDER BY spent DESC;
GROUP_CONCAT — concatenateAGG
Joins values from a group into a single string. Optional separator argument.
SELECT
  department,
  GROUP_CONCAT(name, ', ') AS employees
FROM staff
GROUP BY department;

-- Use DISTINCT to avoid duplicates
SELECT GROUP_CONCAT(DISTINCT country, '|') FROM users;
SUM vs TOTALDETAIL
SUM returns NULL for an empty group, INTEGER if all inputs are integers. TOTAL always returns REAL and gives 0.0 for empty groups — friendlier for reports.
SELECT SUM(price)   FROM orders WHERE id = -1;  -- NULL
SELECT TOTAL(price) FROM orders WHERE id = -1;  -- 0.0
Multiple groupsAGG
SELECT
  strftime('%Y-%m', created_at) AS month,
  country,
  COUNT(*) AS orders,
  AVG(total) AS avg_order
FROM orders
GROUP BY month, country
ORDER BY month DESC, country;
08 / Subqueries & CTEs

Queries inside
queries.

A subquery is a SELECT inside another statement. A CTE (Common Table Expression, the WITH clause) is a named subquery declared at the top — it reads better and can reference itself recursively.

Subquery vs CTE

Subqueries nest inside parentheses — they're concise for one-off filters. CTEs live at the top of the query with a name — they shine for multi-step transformations and self-references.

Performance is generally identical between the two. Pick based on readability.

CTE RECURSIVE · supported
MATERIALIZED hint · 3.35+
Scalar subquerySUB
Returns a single value. Use anywhere a value is expected.
SELECT * FROM orders
WHERE total > (SELECT AVG(total) FROM orders);
IN subquerySUB
SELECT name FROM users
WHERE id IN (SELECT DISTINCT user_id FROM orders WHERE status = 'paid');
Correlated subquerySUB
References a column from the outer query — re-runs for each outer row. Often slow on large datasets; consider a JOIN or window function instead.
SELECT
  u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;
CTE — basic WITHCTE
A named temporary result set. The CTE only lives for the duration of the single statement.
WITH paying_users AS (
  SELECT DISTINCT user_id FROM orders WHERE status = 'paid'
),
top_spenders AS (
  SELECT user_id, SUM(total) AS total
  FROM orders
  WHERE status = 'paid'
  GROUP BY user_id
  HAVING SUM(total) > 500
)
SELECT u.name, t.total
FROM top_spenders t
JOIN users u ON u.id = t.user_id
ORDER BY t.total DESC;
Recursive CTE — hierarchyPOWER
A recursive CTE references itself. Perfect for trees (employee → manager), graphs (linked list traversal), and series generation.
WITH RECURSIVE descendants(id, name, depth) AS (
  SELECT id, name, 0 FROM categories WHERE id = 1  -- anchor
  UNION ALL
  SELECT c.id, c.name, d.depth + 1
  FROM categories c
  JOIN descendants d ON c.parent_id = d.id
)
SELECT * FROM descendants ORDER BY depth;
Generate a seriesPATTERN
Recursive CTEs are also the standard way to generate rows from nothing — a range of dates, a sequence of numbers, a calendar.
WITH RECURSIVE seq(n) AS (
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 100
)
SELECT * FROM seq;

-- Or use the built-in (3.35+)
SELECT * FROM generate_series(1, 100);
Window functionsWINDOW
Compute aggregates across a "window" of related rows without collapsing them. The OVER clause defines the window.
SELECT
  user_id,
  created_at,
  total,
  ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) AS nth_order,
  SUM(total) OVER (PARTITION BY user_id)                    AS lifetime_value,
  LAG(total) OVER (PARTITION BY user_id ORDER BY created_at)   AS prev_total
FROM orders
ORDER BY user_id, created_at;
09 / Indexes

Trading space
for time.

An index is a separate B-tree structure that lets SQLite find rows by a column value without scanning the entire table. Indexes make reads fast and writes slightly slower. Choose them based on the queries you actually run.

When to index

Index columns that appear in WHERE clauses, JOIN conditions, ORDER BY, and GROUP BY. Don't index columns with low cardinality (boolean, status with 3 values) — the index won't help.

Use EXPLAIN QUERY PLAN before and after adding an index to verify it's actually used.

STRUCTURE · B-tree
DEFAULT TYPE · non-unique
PARTIAL · supported
CREATE INDEX — basicIDX
CREATE INDEX idx_users_email ON users(email);
CREATE UNIQUE INDEX idx_users_email ON users(email);  -- enforce uniqueness
Composite indexIDX
Multi-column index. Order matters — the index is usable for queries filtering on the leftmost columns. A composite index on (a, b, c) helps WHERE a=1, WHERE a=1 AND b=2, but NOT WHERE b=2 alone.
CREATE INDEX idx_orders_user_status
ON orders(user_id, status, created_at);

-- Uses the index
SELECT * FROM orders WHERE user_id = 5 AND status = 'paid';
-- Also uses the index (leftmost prefix)
SELECT * FROM orders WHERE user_id = 5;
-- Cannot use the index
SELECT * FROM orders WHERE status = 'paid';
Partial indexIDX
Index only rows matching a WHERE clause. Smaller on disk, faster to maintain — perfect for indexing "active" rows while ignoring the long tail of archived ones.
CREATE INDEX idx_active_users_email
ON users(email)
WHERE deleted_at IS NULL;

-- Smaller index, faster lookups for active users
Expression indexIDX
Index the result of an expression. Useful for case-insensitive lookups or computed columns.
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

-- Uses the index
SELECT * FROM users WHERE LOWER(email) = 'ada@example.com';
EXPLAIN QUERY PLANDEBUG
The most important tool for understanding query performance. Look for "SCAN" (bad — full table scan) vs "SEARCH" (good — uses an index).
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE user_id = 5;

-- Without index:
-- SCAN orders

-- With index on user_id:
-- SEARCH orders USING INDEX idx_orders_user_id (user_id=?)
DROP INDEXIDX
DROP INDEX IF EXISTS idx_users_email;
10 / Views

Saved queries,
virtual tables.

A view is a named SELECT stored in the schema. Querying a view runs its underlying SELECT. Views don't store data — they're a convenience for packaging complex queries behind a simple name.

Why views

Views hide complexity, enforce consistent access patterns, and let you change the underlying schema without breaking every query. The trade-off: there's no materialized view in SQLite (no cached result), so a complex view runs every time.

READ-ONLY · by default
WRITABLE · simple views only
CREATE VIEWVIEW
CREATE VIEW active_users AS
  SELECT id, name, email, last_login
  FROM users
  WHERE deleted_at IS NULL
    AND is_active = 1;

-- Use it like a table
SELECT * FROM active_users WHERE last_login > datetime('now', '-7 days');
Complex view with joinsVIEW
CREATE VIEW order_summary AS
SELECT
  o.id         AS order_id,
  u.name       AS customer,
  o.total,
  o.status,
  COUNT(oi.id) AS item_count
FROM orders o
JOIN users u        ON u.id = o.user_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;

SELECT * FROM order_summary WHERE status = 'paid';
DROP VIEWVIEW
DROP VIEW IF EXISTS active_users;
DROP VIEW IF EXISTS order_summary;
11 / Triggers

Code that runs
on events.

A trigger is SQL that runs automatically when rows are inserted, updated, or deleted. Use them for audit logs, derived columns, and enforcing constraints too complex for CHECK.

Anatomy of a trigger

Every trigger has: a name, a timing (BEFORE or AFTER), an event (INSERT/UPDATE/DELETE), a target table, and a body. Inside the body, the special tables NEW and OLD give access to row values — NEW for the incoming row, OLD for the row being replaced or deleted.

PER-ROW · default and only mode
NEW TABLE · insert/update
OLD TABLE · update/delete
AFTER INSERT — audit logTRIG
CREATE TRIGGER trg_orders_audit
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
  INSERT INTO audit_log (table_name, row_id, action, at)
  VALUES ('orders', NEW.id, 'INSERT', datetime('now'));
END;
BEFORE UPDATE — validate / transformTRIG
BEFORE triggers can modify NEW values before they're written. Use OLD to compare.
CREATE TRIGGER trg_users_normalize_email
BEFORE UPDATE ON users
FOR EACH ROW
WHEN NEW.email != OLD.email
BEGIN
  UPDATE users SET email = LOWER(NEW.email), email_verified = 0
  WHERE id = NEW.id;
END;
AFTER DELETE — cascade cleanupTRIG
CREATE TRIGGER trg_users_cleanup
AFTER DELETE ON users
FOR EACH ROW
BEGIN
  DELETE FROM orders      WHERE user_id = OLD.id;
  DELETE FROM sessions    WHERE user_id = OLD.id;
  DELETE FROM preferences WHERE user_id = OLD.id;
END;
DROP TRIGGERTRIG
DROP TRIGGER IF EXISTS trg_orders_audit;
12 / PRAGMA

Knobs and
introspection.

PRAGMA is SQLite's configuration dialect — a SQL-like command for querying and modifying database behavior. Foreign keys, journal mode, page size, schema introspection — all live behind PRAGMAs.

The PRAGMAs you'll actually use

Most PRAGMAs are runtime settings — they apply to the current connection and reset on reconnect. A few (page_size, journal_mode) persist in the file. Always set foreign_keys=ON at connection start — it's OFF by default for backward compatibility.

SCOPE · per-connection
PERSIST · journal_mode, page_size
Foreign keys — turn them onCRITICAL
Foreign key constraints are OFF by default. Set this on every connection — without it, your FK declarations are decorative.
-- At the start of every connection:
PRAGMA foreign_keys = ON;

-- Verify
PRAGMA foreign_keys;   -- returns 1
Introspect a tableSCHEMA
PRAGMA table_info(users);
-- cid | name      | type    | notnull | dflt_value | pk
-- 0   | id        | INTEGER | 1       | NULL       | 1
-- 1   | email     | TEXT    | 1       | NULL       | 0

PRAGMA foreign_key_list(orders);
PRAGMA index_list(users);
PRAGMA index_info(idx_users_email);
Journal mode — WALPERF
WAL (Write-Ahead Logging) allows concurrent readers during writes. Recommended for almost every workload. Persists in the database file.
PRAGMA journal_mode = WAL;
-- Modes: DELETE (default) | WAL | TRUNCATE | MEMORY | OFF

-- Checkpoint the WAL periodically
PRAGMA wal_checkpoint;
Integrity checkHEALTH
PRAGMA integrity_check;     -- full check, returns 'ok' if healthy
PRAGMA quick_check;          -- faster, less thorough
PRAGMA foreign_key_check;    -- find FK violations
Synchronous — durability vs speedPERF
Controls how aggressively SQLite fsyncs to disk. NORMAL is the recommended setting with WAL — safe across crashes, faster than FULL.
PRAGMA synchronous = NORMAL;  -- recommended with WAL
-- 0 = OFF (risky) | 1 = NORMAL | 2 = FULL (default) | 3 = EXTRA
Memory and cachePERF
PRAGMA cache_size = -64000;   -- 64MB page cache (negative = KB)
PRAGMA temp_store = MEMORY;    -- temp tables in RAM
PRAGMA mmap_size = 268435456; -- 256MB memory-mapped I/O
Query statsDEBUG
PRAGMA stats;                -- table sizes and index info
PRAGMA compile_options;      -- features compiled in
PRAGMA database_list;        -- attached databases
13 / Functions

Built-in verbs.

SQLite ships with a small but complete function library. The dates are the trickiest — SQLite stores them as TEXT in ISO-8601 by convention, and provides strftime for formatting.

Function categories

String Numeric Date/Time Aggregate Flow JSON Window

Custom functions can be registered from C, Python (via create_function), and most other language bindings. This is one of SQLite's superpowers — domain logic can live inside the database.

String functionsSTRING
SELECT
  LENGTH(name),                  -- character count
  LOWER(email),                  -- lowercase
  UPPER(country),                -- uppercase
  SUBSTR(sku, 1, 3),             -- first 3 chars
  TRIM(name),                    -- strip whitespace
  REPLACE(phone, '-', ''),       -- remove dashes
  INSTR(email, '@'),             -- position of '@'
  PRINTF('%05d', id);           -- zero-pad

SELECT || -- concatenation operator
  first_name || ' ' || last_name AS full_name
FROM users;
Numeric functionsNUM
SELECT
  ABS(-42),                -- 42
  ROUND(3.14159, 2),        -- 3.14
  RANDOM(),                -- random integer
  MAX(1, 2, 3),             -- 3 (scalar form)
  MIN(1, 2, 3),             -- 1
  CAST('42' AS INTEGER);   -- type conversion
Date and timeDATE
SQLite stores dates as TEXT (ISO-8601), REAL (Julian day), or INTEGER (Unix seconds). The date functions accept any of these and produce TEXT by default.
-- Current time
SELECT datetime('now');                          -- 2024-01-15 14:32:00
SELECT datetime('now', '+1 day');                -- tomorrow
SELECT datetime('now', '-7 days', 'utc');          -- last week UTC

-- Format with strftime
SELECT strftime('%Y-%m-%d', created_at) AS day FROM orders;
SELECT strftime('%H:%M', login_at) AS hour FROM sessions;
SELECT strftime('%W', created_at) AS week_number;

-- Difference in days
SELECT CAST(
  (julianday('now') - julianday(created_at)) AS INTEGER
) AS days_since_signup FROM users;
Flow controlFLOW
-- COALESCE: first non-NULL value
SELECT COALESCE(nickname, name, 'anonymous');

-- NULLIF: NULL if equal, else first arg
SELECT NULLIF(status, 'draft');   -- NULL for drafts

-- IIF: inline if (3.32+)
SELECT IIF(age >= 18, 'adult', 'minor');
JSON functionsJSON
SQLite has a first-class JSON1 extension (compiled in by default since 3.38). Store JSON in a TEXT column, query and modify it with these functions.
SELECT
  json_extract(payload, '$.user.name'),       -- get field
  json_array_length('[1,2,3]'),               -- 3
  json_type(payload, '$.address'),             -- 'object'
  json_insert(payload, '$.user.age', 36),    -- add if missing
  json_replace(payload, '$.user.age', 37),   -- replace if exists
  json_set(payload, '$.user.age', 37),       -- upsert
  json_remove(payload, '$.temp');               -- delete field

-- JSON in WHERE
SELECT * FROM events
WHERE json_extract(payload, '$.type') = 'click';
Window functionsWINDOW
SELECT
  name,
  salary,
  RANK()       OVER (ORDER BY salary DESC)     AS rank,
  DENSE_RANK() OVER (ORDER BY salary DESC)     AS dense_rank,
  ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS in_dept_rank,
  AVG(salary)  OVER (PARTITION BY dept)         AS dept_avg,
  FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC) AS top_earner
FROM employees;
14 / Patterns & Tips

Hard-won lessons.

The things you learn after years of using SQLite in production. Read these once and remember them.

Always use parameterized queriesSECURITY
Never interpolate values into SQL strings. Use ? or named parameters. This is non-negotiable — SQL injection is the #1 cause of database breaches and it's fully preventable.
-- Bad — SQL injection vulnerable
SELECT * FROM users WHERE email = '-- user input here';

-- Good — parameterized
SELECT * FROM users WHERE email = ?;
Batch writes in transactionsPERF
Each statement outside a transaction triggers a disk sync. Wrapping 1000 inserts in BEGIN/COMMIT can be 50–100× faster.
BEGIN;
INSERT INTO logs (msg) VALUES ('event 1');
INSERT INTO logs (msg) VALUES ('event 2');
-- ... 998 more
COMMIT;
VACUUM to reclaim spaceMAINTENANCE
DELETE doesn't shrink the file — it marks pages as free for reuse. VACUUM rebuilds the database, reclaiming space. Run it after large deletions.
VACUUM;                          -- rebuilds entire DB
PRAGMA auto_vacuum = INCREMENTAL;  -- alternative: incremental
PRAGMA incremental_vacuum(100);    -- free 100 pages
Backup with .dump or Online Backup APIBACKUP
For a cold database, .dump exports SQL text. For a live one, use the Online Backup API (accessible from most language bindings) to copy without locking readers out.
-- CLI backup
sqlite3 mydb.db .dump > backup.sql
sqlite3 backup.db < backup.sql

-- Or copy at file level (only when DB is closed)
cp mydb.db mydb.backup.db
cp mydb.db-wal mydb.backup.db-wal  -- if WAL mode
Import / export CSVCLI
-- Import CSV
sqlite3 mydb.db
.mode csv
.import data.csv my_table

-- Export CSV
.mode csv
.headers on
.output products.csv
SELECT * FROM products;
.output stdout
Store dates as ISO-8601 TEXTCONVENTION
ISO-8601 (YYYY-MM-DD HH:MM:SS) sorts correctly as text, is human-readable, and works with all SQLite date functions natively. Don't store Unix timestamps — you'll regret it when debugging.
CREATE TABLE events (
  id INTEGER PRIMARY KEY,
  created_at TEXT DEFAULT (datetime('now'))
);
-- 2024-01-15 14:32:00 — sorts, parses, formats easily
Use the CLI for quick explorationWORKFLOW
The sqlite3 CLI is a full REPL. Set up column mode once and your life improves immediately.
-- ~/.sqliterc — runs every time the CLI starts
.mode column
.headers on
.nullvalue ∅
.prompt > >>
PRAGMA foreign_keys = ON;
When NOT to use SQLiteSCOPE
SQLite is the right choice for 95% of applications. The 5% where PostgreSQL wins: many concurrent writers (PostgreSQL handles row-level locks better), strict access control across users, very large datasets (>1TB) where sharding matters, or when you need advanced features like materialized views and stored procedures.
-- Use SQLite for:
--   · Mobile and desktop apps
--   · Single-server web apps (low write concurrency)
--   · Embedded systems
--   · Testing and prototyping
--   · Data exchange format (.db files are portable)

-- Consider PostgreSQL for:
--   · High-write-concurrency web services
--   · Multi-tenant systems with strict security
--   · Data warehouses