SQLite Cheatsheet
Queries and Joins
Use this SQLite reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Joins
SQLite supports the full set as of 3.39. Before that, RIGHT JOIN and
FULL OUTER JOIN were a parse error.
SELECT u.email, p.title FROM users u JOIN posts p ON p.author_id = u.id; -- INNER, the default SELECT u.email, count(p.id) AS n FROM users u LEFT JOIN posts p ON p.author_id = u.id -- keep every user GROUP BY u.id; SELECT p.title, u.email FROM posts p RIGHT JOIN users u ON u.id = p.author_id; -- 3.39+ SELECT u.email, p.title FROM users u FULL OUTER JOIN posts p ON p.author_id = u.id; -- 3.39+ SELECT * FROM users CROSS JOIN roles; -- every pair SELECT * FROM users NATURAL JOIN profiles; -- joins on same-named columns SELECT * FROM users JOIN profiles USING(id); -- one shared column name
On 3.38 and older, rewrite the two missing forms. A RIGHT JOIN is a LEFT JOIN with
the tables swapped, and a FULL OUTER JOIN is the union of both directions.
-- FULL OUTER JOIN a b ON a.k=b.k, portable to any version SELECT a.k AS ak, a.v, b.k AS bk, b.w FROM a LEFT JOIN b ON b.k = a.k UNION ALL SELECT NULL, NULL, b.k, b.w FROM b WHERE NOT EXISTS (SELECT 1 FROM a WHERE a.k = b.k);
CROSS JOIN has one special meaning in SQLite: it tells the planner not to reorder
the tables. That makes it a manual override when the query planner picks a bad outer
table, and it means you should not write CROSS JOIN when you meant a plain join.
SELECT sqlite_version(); -- check before relying on RIGHT/FULL
GROUP BY, HAVING, and the bare-column quirk
SQLite lets you select a column that is neither grouped nor aggregated, and it picks an arbitrary row from the group.
-- Legal in SQLite, an error in Postgres. Which title do you get? Undefined. SELECT author_id, title, count(*) FROM posts GROUP BY author_id;
There is exactly one case where the choice is defined: when the query has a single
min() or max() aggregate, the bare columns come from the row that produced it. This
is a documented SQLite guarantee and it is genuinely useful.
-- The newest post per author, defined behavior SELECT author_id, title, max(created_at) AS latest FROM posts GROUP BY author_id;
With two aggregates the guarantee is gone, so use a window function instead.
SELECT author_id, title, created_at FROM ( SELECT author_id, title, created_at, row_number() OVER (PARTITION BY author_id ORDER BY created_at DESC) AS rn FROM posts ) WHERE rn = 1;
SELECT author_id, count(*) AS n, sum(views) AS v, avg(views) AS a FROM posts WHERE status = 'published' GROUP BY author_id HAVING count(*) >= 3 AND sum(views) > 1000 ORDER BY v DESC;
WHERE filters rows before grouping, HAVING filters groups after. HAVING may
reference aggregates, WHERE may not. Both may reference a SELECT alias in SQLite,
which is a convenience the standard does not allow.
Aggregate notes: count(*) counts rows, count(col) skips NULLs, count(DISTINCT col)
works, sum() of no rows is NULL (use coalesce(sum(x),0)), and total() is
SQLite's sum that returns 0.0 instead of NULL. FILTER (3.30+) is the clean way
to do conditional aggregation.
SELECT count(*) AS all_posts, count(*) FILTER (WHERE status='published') AS live, total(views) AS views_total, group_concat(DISTINCT status) AS statuses FROM posts;
Window functions
Window functions arrived in 3.25. Same syntax as everywhere else.
SELECT author_id, title, views, row_number() OVER w AS rn, rank() OVER w AS rnk, dense_rank() OVER w AS drnk, sum(views) OVER (PARTITION BY author_id) AS author_total, views - lag(views) OVER w AS delta, first_value(title) OVER w AS top_title, ntile(4) OVER w AS quartile FROM posts WINDOW w AS (PARTITION BY author_id ORDER BY views DESC);
| Function | Returns |
|---|---|
row_number() | 1, 2, 3, no ties |
rank() | 1, 1, 3, gaps after ties |
dense_rank() | 1, 1, 2, no gaps |
percent_rank() · cume_dist() | Relative position, 0 to 1 |
ntile(n) | Bucket number, 1 to n |
lag(x, off, default) · lead(...) | A neighboring row's value |
first_value(x) · last_value(x) · nth_value(x, n) | Positional values in the frame |
Any aggregate + OVER | sum, count, avg, min, max, group_concat |
Frames matter for running totals. The default frame with an ORDER BY is
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which groups peer rows together, so
a running total over duplicate keys jumps. Use ROWS for a strict row-by-row total.
SELECT day, amount, sum(amount) OVER (ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running, avg(amount) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7 FROM daily ORDER BY day;
Window functions may not appear in WHERE, GROUP BY, or HAVING. Wrap the query in a
subquery or CTE and filter outside, as in the newest-post-per-author example above.
CTEs and WITH RECURSIVE
WITH live AS ( SELECT * FROM posts WHERE status = 'published' ), per_author AS ( SELECT author_id, count(*) AS n FROM live GROUP BY author_id ) SELECT u.email, pa.n FROM per_author pa JOIN users u ON u.id = pa.author_id ORDER BY pa.n DESC;
WITH RECURSIVE builds a sequence or walks a tree. The shape is always: a seed
SELECT, UNION ALL, then a SELECT that references the CTE itself.
-- A number series, the workhorse for generating rows WITH RECURSIVE seq(n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 10 ) SELECT n FROM seq; -- Every day in a month WITH RECURSIVE d(day) AS ( SELECT date('2026-01-01') UNION ALL SELECT date(day, '+1 day') FROM d WHERE day < '2026-01-31' ) SELECT day FROM d; -- Walk a parent tree downward, tracking depth and path WITH RECURSIVE tree(id, name, parent_id, depth, path) AS ( SELECT id, name, parent_id, 0, name FROM nodes WHERE parent_id IS NULL UNION ALL SELECT n.id, n.name, n.parent_id, t.depth + 1, t.path || ' > ' || n.name FROM nodes n JOIN tree t ON n.parent_id = t.id ) SELECT depth, path FROM tree ORDER BY path;
Use UNION instead of UNION ALL to deduplicate, which also protects a cyclic graph
from looping forever. LIMIT on the outer query stops a recursive CTE early, and
SQLite also accepts a LIMIT inside it. MATERIALIZED and NOT MATERIALIZED
(3.35+) control whether a CTE is computed once into a temp table or inlined.
WITH expensive AS MATERIALIZED (SELECT ... ) -- compute once SELECT * FROM expensive a JOIN expensive b ON ...;
LIMIT, OFFSET, and set operations
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20; SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40; SELECT * FROM posts ORDER BY created_at DESC LIMIT 40, 20; -- MySQL form: offset, limit SELECT * FROM posts ORDER BY random() LIMIT 1; -- a random row
LIMIT 40, 20 puts the offset first, which is the opposite order from LIMIT 20
OFFSET 40. Prefer the explicit OFFSET keyword.
OFFSET still reads and discards every skipped row, so deep pagination degrades
linearly. Keyset pagination does not.
-- Page 1 SELECT id, created_at, title FROM posts ORDER BY created_at DESC, id DESC LIMIT 20; -- Next page, using the last row's values SELECT id, created_at, title FROM posts WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20;
Row-value comparison in a WHERE clause needs 3.15+.
SELECT a FROM t1 UNION SELECT a FROM t2; -- dedupes SELECT a FROM t1 UNION ALL SELECT a FROM t2; -- keeps duplicates, faster SELECT a FROM t1 INTERSECT SELECT a FROM t2; SELECT a FROM t1 EXCEPT SELECT a FROM t2;
A single ORDER BY and LIMIT at the end applies to the whole compound query, and it
may only reference the first select's output column names.
Conditional and NULL handling
SELECT CASE WHEN views > 1000 THEN 'hot' WHEN views > 100 THEN 'warm' ELSE 'cold' END AS heat, CASE status WHEN 'draft' THEN 0 ELSE 1 END AS is_live, iif(views > 100, 'popular', 'quiet') AS simple, -- 3.32+ coalesce(nickname, name, 'anonymous') AS display, ifnull(nickname, name) AS two_arg, nullif(views, 0) AS views_or_null, max(1, 2, 3) AS scalar_max, min(views, 100) AS capped FROM posts;
| Function | Behavior |
|---|---|
iif(c, a, b) | 3.32+. Sugar for a two-branch CASE |
coalesce(a, b, ...) | First non-NULL. Needs at least two arguments |
ifnull(a, b) | Exactly two arguments. Same as coalesce with two |
nullif(a, b) | NULL when a = b, else a. Good for divide-by-zero guards |
max(a, b, ...) / min(...) | With 2+ arguments these are scalar, not aggregates |
unlikely(x) · likely(x) | Planner hints, return x unchanged |
NULL rules that bite: NULL = NULL is NULL, not true, so use IS NULL and
IS NOT NULL, or the IS and IS NOT operators which compare NULLs as equal.
x IN (1, NULL) is NULL rather than false when x is not 1, so NOT IN against a
subquery that can return NULL silently returns nothing.
SELECT NULL = NULL; -- (empty, meaning NULL) SELECT NULL IS NULL; -- 1 SELECT 1 IS NOT 2; -- 1, works on non-NULL values too SELECT 5 NOT IN (1, NULL); -- NULL, so a WHERE on it matches nothing SELECT 5 NOT IN (SELECT x FROM t WHERE x IS NOT NULL); -- the fix SELECT 1/0, 1.0/0; -- both NULL, SQLite does not raise SELECT views / nullif(total, 0) FROM stats; -- safe division
Division by zero returns NULL rather than an error, and integer division truncates,
so SELECT 7/2 is 3. Multiply by 1.0 for a real result.
String, math, and misc functions
SELECT length('héllo'), -- 5, characters for text length(x'0102'), -- 2, bytes for a blob octet_length('héllo'), -- 6, bytes. 3.43+ upper('abc'), lower('ABC'), -- ASCII only, not Unicode-aware trim(' x '), ltrim('xxa','x'), rtrim('axx','x'), substr('abcdef', 2, 3), -- 'bcd', 1-based substr('abcdef', -2), -- 'ef', negative counts from the end instr('abcdef', 'cd'), -- 3, 0 when absent replace('a-b-c', '-', '+'), -- 'a+b+c' 'a' || 'b' || 'c', -- concatenation. NULL || 'x' is NULL concat('a', NULL, 'b'), -- 'ab', skips NULLs. 3.44+ concat_ws('-', 'a', NULL, 'b'), -- 'a-b'. 3.44+ printf('%s has %d at %.2f', 'x', 3, 1.5), format('%05d', 42), -- '00042'. 3.38+ alias of printf hex('ab'), quote('it''s'), char(65, 66), unicode('A'), group_concat(status, ', '), string_agg(status, ', ') -- 3.44+ alias of group_concat FROM posts;
SELECT abs(-3), round(2.567, 2), ceil(2.1), floor(2.9), -- ceil/floor 3.35+ cast(7 AS REAL)/2, 7%2, 7/2, random(), abs(random()) % 100, -- random() is a signed 64-bit integer randomblob(16), hex(randomblob(16)), -- a random id min(3,1,2), max(3,1,2), sqrt(16), pow(2,10), ln(exp(1)), log(100), log2(8), -- math funcs 3.35+ sqlite_version(), sqlite_source_id(), total_changes(), changes(), last_insert_rowid();
The math functions (sqrt, pow, ln, log, sin, ceil, floor) need 3.35+
and a build compiled with SQLITE_ENABLE_MATH_FUNCTIONS, which the standard builds have.
upper() and lower() only fold ASCII, so upper('é') is still 'é' unless the ICU
extension is loaded.
LIKE, GLOB, and REGEXP
| Operator | Wildcards | Case |
|---|---|---|
LIKE | % any run, _ one char | Case insensitive for ASCII by default |
GLOB | * any run, ? one char, [a-z] classes | Always case sensitive |
REGEXP | Full regex | Not built in, needs an extension |
MATCH | Depends on the virtual table | FTS5 uses it for full-text queries |
SELECT 'Hello' LIKE 'hello'; -- 1, LIKE ignores ASCII case SELECT 'HÉLLO' LIKE 'héllo'; -- 0, non-ASCII case is NOT folded SELECT 'Hello' GLOB 'hello'; -- 0 SELECT 'Hello' GLOB 'H*o'; -- 1 SELECT 'a.c' LIKE 'a\.c' ESCAPE '\'; -- escape a literal % or _ SELECT 'abc' REGEXP 'a.c'; -- Error: no such function: REGEXP
Index usage is the part that matters. A LIKE 'prefix%' can use an index only when
the column is COLLATE NOCASE or PRAGMA case_sensitive_like is ON, because the default
case-insensitive LIKE does not match the default BINARY index ordering. GLOB needs no
such trick.
CREATE TABLE u(e TEXT COLLATE NOCASE); CREATE INDEX ue ON u(e); EXPLAIN QUERY PLAN SELECT * FROM u WHERE e LIKE 'ada%'; -- SEARCH u USING COVERING INDEX ue (e>? AND e<?) CREATE TABLE v(e TEXT); CREATE INDEX ve ON v(e); EXPLAIN QUERY PLAN SELECT * FROM v WHERE e LIKE 'ada%'; -- SCAN v <- full scan EXPLAIN QUERY PLAN SELECT * FROM v WHERE e GLOB 'ada*'; -- SEARCH v USING COVERING INDEX ve (e>? AND e<?)
A leading % never uses an index in any engine. For infix search use FTS5 with the
trigram tokenizer.
EXPLAIN QUERY PLAN
EXPLAIN QUERY PLAN prints the strategy. EXPLAIN alone prints the bytecode, which you
rarely need.
EXPLAIN QUERY PLAN SELECT u.email, p.title FROM users u JOIN posts p ON p.author_id = u.id WHERE u.email = 'a@b.c' ORDER BY p.created_at DESC;
| Line starts with | Meaning |
|---|---|
SCAN t | A full table scan. Fine on a small table, a problem on a large one |
SEARCH t USING INDEX ix (col=?) | An index seek, what you want |
SEARCH t USING COVERING INDEX ix | The index alone answered it, no table read |
SEARCH t USING INTEGER PRIMARY KEY (rowid=?) | The fastest possible lookup |
USE TEMP B-TREE FOR ORDER BY | Sorting in memory, add a matching index |
USE TEMP B-TREE FOR GROUP BY | Same, for grouping |
SEARCH t USING AUTOMATIC COVERING INDEX | SQLite built a throwaway index. Add a real one |
MULTI-INDEX OR | An OR satisfied by two index lookups |
CORRELATED SCALAR SUBQUERY | A subquery re-run per row |
AUTOMATIC COVERING INDEX is the strongest hint in the output: SQLite decided a scan was
so bad it was worth building an index on the fly, and then throwing it away. Create it
permanently.
In the CLI, .eqp on runs the plan before every query, and .eqp full adds the
bytecode. Run ANALYZE so the planner has real statistics, and PRAGMA optimize on
connection close to keep them fresh.