SQLite Cheatsheet
Tables and DDL
Use this SQLite reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
CREATE TABLE and the rowid
Every table has a hidden 64-bit rowid unless it is WITHOUT ROWID. Declaring exactly
INTEGER PRIMARY KEY makes that column an alias for the rowid, which costs no extra
storage and no extra index.
CREATE TABLE users( id INTEGER PRIMARY KEY, -- this IS the rowid, the fast path email TEXT NOT NULL UNIQUE, name TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ) STRICT;
| Declaration | Result |
|---|---|
id INTEGER PRIMARY KEY | Rowid alias. One B-tree, lookups are direct |
id INT PRIMARY KEY | Not an alias. A separate index, and it allows NULL |
id BIGINT PRIMARY KEY | Not an alias either. The spelling must be INTEGER |
id INTEGER PRIMARY KEY DESC | Not an alias, because of the DESC |
PRIMARY KEY(id) with id INTEGER | Still an alias, the separate-clause form is fine |
id TEXT PRIMARY KEY | A separate unique index, rowid still exists underneath |
The rowid is accessible as rowid, oid, or _rowid_ when the table has no column of
that name.
SELECT rowid, * FROM users LIMIT 1; SELECT max(rowid) FROM users;
A PRIMARY KEY column allows NULL in a non-STRICT, non-WITHOUT ROWID table, unless
it is the INTEGER PRIMARY KEY rowid alias. That is a documented long-standing bug kept
for compatibility. Write NOT NULL yourself, or use STRICT.
AUTOINCREMENT, and why to skip it
Without AUTOINCREMENT, a new rowid is max(rowid) + 1, so deleting the last row means
the next insert reuses that number. With AUTOINCREMENT, SQLite keeps a
high-water mark in the internal sqlite_sequence table and never reuses a value.
CREATE TABLE a(id INTEGER PRIMARY KEY, v TEXT); INSERT INTO a(v) VALUES('x'),('y'); DELETE FROM a WHERE id=2; INSERT INTO a(v) VALUES('z'); SELECT id, v FROM a; -- 1|x and 2|z <- id 2 was reused CREATE TABLE b(id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT); INSERT INTO b(v) VALUES('x'),('y'); DELETE FROM b WHERE id=2; INSERT INTO b(v) VALUES('z'); SELECT id, v FROM b; -- 1|x and 3|z <- never reused SELECT * FROM sqlite_sequence; -- b|3
Plain INTEGER PRIMARY KEY | With AUTOINCREMENT | |
|---|---|---|
| Reuses ids after a delete | Yes | No |
| Extra table and write per insert | No | Yes, sqlite_sequence |
| Behavior at 9223372036854775807 | Picks a random free rowid | SQLITE_FULL error |
| Cost | Free | A measurable insert penalty |
Use AUTOINCREMENT only when a reused id would be a real problem, for example when ids
leak into URLs or into an external system that caches them. Otherwise skip it, which is
the official recommendation too.
WITHOUT ROWID and STRICT
WITHOUT ROWID (3.8.2+) stores the row in the primary key's B-tree directly, so
there is no second lookup through the rowid.
CREATE TABLE kv( k TEXT PRIMARY KEY, v TEXT NOT NULL ) STRICT, WITHOUT ROWID;
Requirements and consequences:
- A PRIMARY KEY is required, and it is implicitly NOT NULL.
- There is no rowid, so SELECT rowid fails and last_insert_rowid() is useless.
- AUTOINCREMENT is not allowed.
- FTS5 external-content tables and some other virtual tables need a rowid, so they
cannot point at a WITHOUT ROWID table.
Good fit: a natural text or composite primary key, and short rows. Bad fit: wide rows with a long primary key, since the key is repeated in every secondary index.
Constraints and defaults
CREATE TABLE posts( id INTEGER PRIMARY KEY, author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, slug TEXT NOT NULL COLLATE NOCASE, title TEXT NOT NULL CHECK(length(trim(title)) > 0), status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft','published','archived')), views INTEGER NOT NULL DEFAULT 0 CHECK(views >= 0), published_at TEXT CHECK(published_at IS NULL OR published_at LIKE '____-__-__%'), created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(author_id, slug), CHECK(status <> 'published' OR published_at IS NOT NULL) ) STRICT;
| Constraint | Notes |
|---|---|
NOT NULL | Enforced always, including in non-STRICT tables |
UNIQUE | Creates an index. NULLs are all distinct, so many NULLs are allowed |
CHECK(expr) | Passes when the expression is true or NULL, so guard for NULL |
DEFAULT 'x' | A constant |
DEFAULT (expr) | A parenthesized expression, (datetime('now')), (random()) |
DEFAULT CURRENT_TIMESTAMP | YYYY-MM-DD HH:MM:SS in UTC |
COLLATE NOCASE | Case-insensitive comparison and sorting, ASCII only |
REFERENCES t(c) | A foreign key, inert unless PRAGMA foreign_keys=ON |
CHECK(x > 0) does not reject NULL, because the check only fails on a definitively false
result. Write CHECK(x IS NOT NULL AND x > 0) or add NOT NULL.
Collations are BINARY (the default), NOCASE (ASCII case folding only, so Ä and ä
still differ), and RTRIM. A collation declared on the column applies to comparisons and
to any index on that column.
CREATE TABLE e(email TEXT UNIQUE COLLATE NOCASE) STRICT; INSERT INTO e VALUES('Ada@Example.com'); INSERT INTO e VALUES('ada@example.com'); -- Error: UNIQUE constraint failed: e.email
Generated columns
Generated columns (3.31+) compute a value from other columns in the same row.
CREATE TABLE items( id INTEGER PRIMARY KEY, price INTEGER NOT NULL, -- cents qty INTEGER NOT NULL, total INTEGER AS (price * qty), -- VIRTUAL, the default payload TEXT NOT NULL, sku TEXT AS (payload ->> '$.sku') STORED, slug TEXT GENERATED ALWAYS AS (lower(payload ->> '$.name')) VIRTUAL ) STRICT;
VIRTUAL (default) | STORED | |
|---|---|---|
| Disk cost | None | A column per row |
| Read cost | Recomputed per read | Free |
| Can be indexed | Yes | Yes |
Can be added by ALTER TABLE ADD COLUMN | Yes | No |
Rules: the expression may only reference columns of the same row, it must be
deterministic (no random(), no datetime('now') without a fixed argument), it cannot
have a DEFAULT, and it cannot be part of the primary key. You never write to a
generated column, and INSERT INTO items VALUES(...) without a column list must skip
them, so always name your columns.
Foreign keys are OFF by default
This is the most consequential default in SQLite. Foreign key constraints are parsed and stored but not enforced unless the connection turns them on, every single time.
PRAGMA foreign_keys; -- 0 on a fresh connection PRAGMA foreign_keys = ON; -- per connection, not persisted in the file PRAGMA foreign_keys; -- 1
PRAGMA foreign_keys = OFF; INSERT INTO posts(author_id, slug, title) VALUES(999, 'x', 'T'); -- accepted PRAGMA foreign_keys = ON; INSERT INTO posts(author_id, slug, title) VALUES(999, 'y', 'T'); -- Error: FOREIGN KEY constraint failed
Set
PRAGMA foreign_keys = ONas the first statement on every connection, in your pool's on-connect hook. There is no file-level or database-level setting, and a connection that forgets writes orphan rows the enforcing connections cannot see.PRAGMA foreign_keysis also a no-op inside a transaction, so run it beforeBEGIN.
Referential actions, on ON DELETE and ON UPDATE:
| Action | Effect on the child row |
|---|---|
NO ACTION | Nothing. The parent change fails at statement end. The default |
RESTRICT | The parent change fails immediately, before other triggers |
CASCADE | Deleted, or the key value updated to match |
SET NULL | The key columns become NULL |
SET DEFAULT | The key columns take their DEFAULT |
PRAGMA foreign_key_check; -- list every violating row, whole database PRAGMA foreign_key_check(posts); -- one table PRAGMA foreign_key_list('posts'); -- the declared keys of a table PRAGMA defer_foreign_keys = ON; -- defer to COMMIT for this transaction only
A foreign key must point at a PRIMARY KEY or UNIQUE column set in the parent, or you
get foreign key mismatch at insert time rather than at CREATE TABLE time.
Indexes, views, triggers
CREATE INDEX idx_posts_author ON posts(author_id, created_at DESC); CREATE UNIQUE INDEX idx_posts_slug ON posts(slug); CREATE INDEX idx_posts_live ON posts(created_at) WHERE status = 'published'; -- partial index, 3.8.0+ CREATE INDEX idx_users_lower ON users(lower(email)); -- expression index, 3.9.0+ CREATE INDEX idx_posts_cover ON posts(author_id, status, title); -- covering DROP INDEX IF EXISTS idx_posts_slug; REINDEX posts; -- rebuild, after a collation change
An expression index is only used when the query repeats the expression exactly, so
WHERE lower(email) = ? hits idx_users_lower and WHERE email = ? does not. A partial
index is only used when the planner can prove the WHERE clause implies the index's own
predicate.
EXPLAIN QUERY PLAN SELECT id FROM posts WHERE status='published' ORDER BY created_at; -- SEARCH posts USING INDEX idx_posts_live (>?)
CREATE VIEW live_posts AS SELECT p.id, p.title, u.email FROM posts p JOIN users u ON u.id = p.author_id WHERE p.status = 'published';
Views are read-only. To make one writable, attach INSTEAD OF triggers.
CREATE TRIGGER posts_touch AFTER UPDATE ON posts FOR EACH ROW WHEN old.title <> new.title BEGIN UPDATE posts SET updated_at = datetime('now') WHERE id = old.id; END; CREATE TRIGGER posts_no_delete BEFORE DELETE ON posts BEGIN SELECT RAISE(ABORT, 'posts are archived, not deleted'); END; CREATE TRIGGER live_posts_ins INSTEAD OF INSERT ON live_posts BEGIN INSERT INTO posts(author_id, slug, title, status) VALUES(1, new.title, new.title, 'published'); END;
Trigger notes: SQLite triggers are FOR EACH ROW only, there is no statement-level
trigger. RAISE(ABORT|FAIL|ROLLBACK|IGNORE, 'msg') is how a trigger rejects. new and
old are available per event (new on insert, both on update, old on delete).
PRAGMA recursive_triggers is OFF by default, so a trigger that modifies its own table
does not re-fire.
ALTER TABLE, and the twelve-step rebuild
ALTER TABLE supports exactly four operations.
ALTER TABLE posts RENAME TO articles; -- 3.25+ also fixes references ALTER TABLE posts RENAME COLUMN slug TO url_slug; -- 3.25+ ALTER TABLE posts ADD COLUMN pinned INTEGER DEFAULT 0; ALTER TABLE posts DROP COLUMN pinned; -- 3.35+
There is no ALTER COLUMN, no MODIFY, no way to change a type, add or drop a
constraint, reorder columns, or add STRICT. ADD COLUMN is also restricted:
ALTER TABLE t ADD COLUMN y TEXT NOT NULL; -- Error: Cannot add a NOT NULL column with default value NULL -- (only on a non-empty table, and only when there is no DEFAULT) ALTER TABLE t ADD COLUMN y TEXT DEFAULT (datetime('now')); -- Error: Cannot add a column with non-constant default ALTER TABLE t ADD COLUMN y INTEGER PRIMARY KEY; -- Error: Cannot add a PRIMARY KEY column ALTER TABLE t ADD COLUMN y TEXT UNIQUE; -- Error: Cannot add a UNIQUE column ALTER TABLE t ADD COLUMN y TEXT AS (x || '!') STORED; -- Error: cannot add a STORED column ALTER TABLE t ADD COLUMN y TEXT AS (x || '!'); -- VIRTUAL is allowed
DROP COLUMN fails on a column that is a primary key, is UNIQUE, is named in a
partial index or a CHECK or a foreign key, or is referenced by any index or trigger or
view. Drop the dependent object first.
For everything else, the official procedure from the ALTER TABLE documentation. Do all twelve steps, in order.
-- 1. If foreign keys are on, turn them off PRAGMA foreign_keys = OFF; -- 2. Start a transaction BEGIN; -- 3. Save the CREATE statements of every index, trigger, and view on the table SELECT type, sql FROM sqlite_schema WHERE tbl_name = 'posts'; -- 4. Create the new table under a temporary name, with the schema you want CREATE TABLE posts_new( id INTEGER PRIMARY KEY, author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, slug TEXT NOT NULL, title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft' ) STRICT; -- 5. Copy the data across, transforming as needed INSERT INTO posts_new(id, author_id, slug, title, status) SELECT id, author_id, slug, title, coalesce(status,'draft') FROM posts; -- 6. Drop the old table DROP TABLE posts; -- 7. Rename the new table into place ALTER TABLE posts_new RENAME TO posts; -- 8. Recreate the indexes, triggers, and views from step 3 CREATE INDEX idx_posts_author ON posts(author_id); -- 9. Recreate any views that referenced the table -- 10. If foreign keys were on, check them PRAGMA foreign_key_check; -- 11. Commit COMMIT; -- 12. Turn foreign keys back on PRAGMA foreign_keys = ON;
Three details that matter. PRAGMA foreign_keys=OFF must be outside the
transaction, since the pragma is a no-op inside one. Step 6 before step 7 is deliberate,
because with legacy_alter_table off the RENAME in step 7 would otherwise rewrite
references in other tables to point at the temporary name. And if any view or trigger
elsewhere referenced the old table, check it after step 9, since a dangling reference
only errors when the view is used.