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;
DeclarationResult
id INTEGER PRIMARY KEYRowid alias. One B-tree, lookups are direct
id INT PRIMARY KEYNot an alias. A separate index, and it allows NULL
id BIGINT PRIMARY KEYNot an alias either. The spelling must be INTEGER
id INTEGER PRIMARY KEY DESCNot an alias, because of the DESC
PRIMARY KEY(id) with id INTEGERStill an alias, the separate-clause form is fine
id TEXT PRIMARY KEYA 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 KEYWith AUTOINCREMENT
Reuses ids after a deleteYesNo
Extra table and write per insertNoYes, sqlite_sequence
Behavior at 9223372036854775807Picks a random free rowidSQLITE_FULL error
CostFreeA 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;
ConstraintNotes
NOT NULLEnforced always, including in non-STRICT tables
UNIQUECreates 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_TIMESTAMPYYYY-MM-DD HH:MM:SS in UTC
COLLATE NOCASECase-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 costNoneA column per row
Read costRecomputed per readFree
Can be indexedYesYes
Can be added by ALTER TABLE ADD COLUMNYesNo

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 = ON as 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_keys is also a no-op inside a transaction, so run it before BEGIN.

Referential actions, on ON DELETE and ON UPDATE:

ActionEffect on the child row
NO ACTIONNothing. The parent change fails at statement end. The default
RESTRICTThe parent change fails immediately, before other triggers
CASCADEDeleted, or the key value updated to match
SET NULLThe key columns become NULL
SET DEFAULTThe 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.