Cheat sheet · SQL
SQL Cheat Sheet
A scannable SQL reference covering SELECT and WHERE, sorting and limiting, aggregates and GROUP BY, joins, subqueries and CTEs, window functions, INSERT, UPDATE and DELETE, transactions, constraints, and the SQLite-specific behaviour behind each one.
A cheat sheet is for the thing you have understood once and cannot quite remember the shape of. It is written to be scanned, so the common cases come first. Starting from nothing? The SQL exercises are the right first step; come back here once the syntax is something you are recalling rather than meeting. Looking for another language? See every cheat sheet.
This page is a fast-scanning reference for the SQL you reach for constantly — not a tutorial. Every snippet below is self-contained and was run in this site's SQL playground to produce the output shown under it, so you can paste any block straight into the playground and get the same table back.
Two things shape every example here. The playground is real SQLite 3.49, and every run gets a brand-new, empty database — so each snippet creates and fills its own tiny table before querying it. Where SQLite behaves differently from PostgreSQL or MySQL in a way you will actually meet, it is called out on the spot.
How a Script Runs Here
Statements run in order, separated by semicolons. Every statement that returns columns prints a table with a row count underneath; statements that return nothing print nothing. An error stops the run and names the line the failing statement starts on.
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, deposit INTEGER);
INSERT INTO tools (name, deposit) VALUES ('hand saw', 350), ('tile cutter', 1500);
SELECT count(*) AS tools FROM tools;
SELECT name FROM tools ORDER BY name;tools
-----
2
(1 row)
name
-----------
hand saw
tile cutter
(2 rows)A script with no query in it is not an error — there is simply nothing to show:
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO tools (name) VALUES ('hand saw');Done. The statements ran, but none of them returned rows. Add a SELECT to see data.Clause Order
Write the clauses in this order, or you get a syntax error:
SELECT → FROM → JOIN ... ON → WHERE → GROUP BY → HAVING → ORDER BY → LIMIT / OFFSET
The database evaluates them in a different order, which is what explains most "why can't I use that here"
errors: FROM and JOIN first, then WHERE, then GROUP BY, then HAVING, then the SELECT list and its
aliases, then window functions, then ORDER BY, then LIMIT.
WHEREcannot see aSELECTalias or an aggregate (SQLite allows the alias anyway; PostgreSQL does not).HAVINGcan test aggregates, because the groups exist by then.ORDER BYcan use aSELECTalias, because the list has already run.- A window function cannot go in
WHERE,GROUP BYorHAVING— put it in a CTE and filter outside.
Creating Tables, Types and Constraints
SQLite has five storage classes — NULL, INTEGER, REAL, TEXT, BLOB — and a column's declared type is
only an affinity, not a rule, unless the table is created STRICT. There is no BOOLEAN and no date or
time type: booleans are 0 and 1, dates are TEXT in YYYY-MM-DD form.
CREATE TABLE members (
id INTEGER PRIMARY KEY, -- auto-fills with the next integer
name TEXT NOT NULL, -- must always have a value
email TEXT UNIQUE, -- no two rows alike; NULLs allowed, any number of them
joined TEXT DEFAULT '2026-01-01', -- used when INSERT omits the column
credits INTEGER NOT NULL DEFAULT 0 CHECK (credits >= 0),
active INTEGER NOT NULL DEFAULT 1 -- SQLite has no BOOLEAN: 0 or 1
);
INSERT INTO members (name, email) VALUES ('Asha', 'asha@example.invalid');
INSERT INTO members (name, email, credits) VALUES ('Bruno', 'bruno@example.invalid', 3);
SELECT * FROM members;
SELECT name, typeof(credits) AS credits_type, typeof(joined) AS joined_type FROM members LIMIT 1;id | name | email | joined | credits | active
---+-------+-----------------------+------------+---------+-------
1 | Asha | asha@example.invalid | 2026-01-01 | 0 | 1
2 | Bruno | bruno@example.invalid | 2026-01-01 | 3 | 1
(2 rows)
name | credits_type | joined_type
-----+--------------+------------
Asha | integer | text
(1 row)A CHECK failure names the rule it broke:
CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT NOT NULL, credits INTEGER CHECK (credits >= 0));
INSERT INTO members (name, credits) VALUES ('Asha', 2);
SELECT name, credits FROM members;
INSERT INTO members (name, credits) VALUES ('Bruno', -1);name | credits
-----+--------
Asha | 2
(1 row)
Error in the statement on line 4: CHECK constraint failed: credits >= 0Other useful table statements: ALTER TABLE t ADD COLUMN c TEXT, ALTER TABLE t RENAME TO t2,
ALTER TABLE t DROP COLUMN c (SQLite 3.35+), DROP TABLE IF EXISTS t,
CREATE TABLE copy AS SELECT ... (copies rows but not constraints). SQLite has no TRUNCATE TABLE —
use DELETE FROM t.
Foreign Keys (and the PRAGMA)
SQLite does not enforce foreign keys unless you switch them on, per connection. This is the single most consequential difference from PostgreSQL and MySQL, where they are always enforced.
PRAGMA foreign_keys = ON;
CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE loans (
id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
taken_on TEXT NOT NULL
);
INSERT INTO members (id, name) VALUES (1, 'Asha');
INSERT INTO loans (member_id, taken_on) VALUES (1, '2026-03-04');
SELECT * FROM loans;
INSERT INTO loans (member_id, taken_on) VALUES (99, '2026-03-05');id | member_id | taken_on
---+-----------+-----------
1 | 1 | 2026-03-04
(1 row)
Error in the statement on line 14: FOREIGN KEY constraint failedDelete behaviour is declared on the key: the default refuses the delete, ON DELETE CASCADE deletes the
pointing rows too, ON DELETE SET NULL blanks the column.
SELECT, Aliases and Computed Columns
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES
('hedge trimmer', 'garden', 950), ('cordless drill', 'power', 1200), ('hand saw', 'hand', 350);
SELECT * FROM tools; -- every column, exploring only
SELECT shelf, name FROM tools; -- named columns, in the order you asked
SELECT
name,
deposit / 100.0 AS pounds, -- 100.0 not 100: see Numbers below
upper(shelf) AS shelf_label,
name || ' (' || shelf || ')' AS label -- || joins text
FROM tools;
SELECT 2 + 2 AS no_table_needed; -- FROM is optional in SQLiteid | name | shelf | deposit
---+----------------+--------+--------
1 | hedge trimmer | garden | 950
2 | cordless drill | power | 1200
3 | hand saw | hand | 350
(3 rows)
shelf | name
-------+---------------
garden | hedge trimmer
power | cordless drill
hand | hand saw
(3 rows)
name | pounds | shelf_label | label
---------------+--------+-------------+-----------------------
hedge trimmer | 9.5 | GARDEN | hedge trimmer (garden)
cordless drill | 12 | POWER | cordless drill (power)
hand saw | 3.5 | HAND | hand saw (hand)
(3 rows)
no_table_needed
---------------
4
(1 row)Use single quotes for text and double quotes for identifiers. SQLite quietly treats an unmatched double-quoted name as text, which hides a bug that PostgreSQL would have reported.
WHERE and Operators
=and<>(also spelled!=) — equal and not equal. Equality is one=, never==.<<=>>=— ordering, on numbers and on text alike.ANDORNOT—ANDbinds tighter thanOR, so write the brackets.IN (a, b, c)— any of a list, or of a subquery.BETWEEN a AND b— inclusive of both bounds.LIKE 'a%'— pattern matching:%is any run of characters,_is exactly one.IS NULLandIS NOT NULL— the only tests that work onNULL.
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES
('hedge trimmer', 'garden', 950), ('cordless drill', 'power', 1200),
('wheelbarrow', 'garden', 500), ('Hammer', 'hand', 350), ('donated ladder', 'garden', NULL);
SELECT name FROM tools WHERE deposit > 500 AND shelf <> 'power';
SELECT name FROM tools WHERE shelf IN ('garden', 'hand') AND deposit BETWEEN 350 AND 950;
SELECT name FROM tools WHERE name LIKE '%er'; -- SQLite LIKE ignores ASCII case
SELECT name FROM tools WHERE deposit IS NULL;
SELECT name FROM tools WHERE COALESCE(deposit, 0) < 400; -- substitute rather than lose the rowname
-------------
hedge trimmer
(1 row)
name
-------------
hedge trimmer
wheelbarrow
Hammer
(3 rows)
name
--------------
hedge trimmer
Hammer
donated ladder
(3 rows)
name
--------------
donated ladder
(1 row)
name
--------------
Hammer
donated ladder
(2 rows)Note the third result: LIKE '%er' matched Hammer, because SQLite's LIKE is case-insensitive for ASCII
letters while = is case-sensitive. PostgreSQL is the other way round (LIKE is case-sensitive, ILIKE
is not); MySQL follows its collation. Use ESCAPE to match a literal % or _:
WHERE body LIKE '%!%%' ESCAPE '!'.
NULL Rules
SELECT
NULL = NULL AS equals_is_unknown, -- NULL, printed as an empty-ish NULL
NULL IS NULL AS is_null_works,
1 + NULL AS arithmetic_spreads,
'a' || NULL AS concat_spreads,
COALESCE(NULL, NULL, 'fallback') AS coalesce_first_non_null,
IFNULL(NULL, 0) AS sqlite_ifnull,
NULLIF(5, 5) AS nullif_when_equal;equals_is_unknown | is_null_works | arithmetic_spreads | concat_spreads | coalesce_first_non_null | sqlite_ifnull | nullif_when_equal
------------------+---------------+--------------------+----------------+-------------------------+---------------+------------------
NULL | 1 | NULL | NULL | fallback | 0 | NULL
(1 row)Three consequences worth memorising:
WHERE col = NULLnever matches. UseIS NULL.WHERE col > 5andWHERE col <= 5do not add up to the whole table whencolcan beNULL.col NOT IN (list containing NULL)returns no rows at all. PreferNOT EXISTS.
DISTINCT, GROUP BY and UNION are the exceptions: they treat all NULLs as one value.
ORDER BY, LIMIT, DISTINCT
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES
('hedge trimmer', 'garden', 950), ('cordless drill', 'power', 1200),
('wheelbarrow', 'garden', 500), ('Hammer', 'hand', 350), ('donated ladder', 'garden', NULL);
SELECT name, deposit FROM tools ORDER BY deposit DESC NULLS LAST LIMIT 2;
SELECT name, deposit FROM tools ORDER BY deposit; -- NULL sorts first ascending
SELECT name FROM tools ORDER BY name COLLATE NOCASE; -- case-insensitive sort
SELECT shelf, count(*) AS n FROM tools GROUP BY shelf ORDER BY n DESC, shelf LIMIT 2 OFFSET 1;
SELECT DISTINCT shelf FROM tools ORDER BY shelf;name | deposit
---------------+--------
cordless drill | 1200
hedge trimmer | 950
(2 rows)
name | deposit
---------------+--------
donated ladder | NULL
Hammer | 350
wheelbarrow | 500
hedge trimmer | 950
cordless drill | 1200
(5 rows)
name
--------------
cordless drill
donated ladder
Hammer
hedge trimmer
wheelbarrow
(5 rows)
shelf | n
------+--
hand | 1
power | 1
(2 rows)
shelf
------
garden
hand
power
(3 rows)- No
ORDER BYmeans no guaranteed order, however tidy the result looks today. NULLS LASTworks in SQLite 3.30+ and PostgreSQL; MySQL does not accept it. Portable version:ORDER BY deposit IS NULL, deposit.LIMIT n OFFSET kis SQLite, PostgreSQL and MySQL. SQL Server usesTOPorOFFSET ... FETCH.DISTINCTapplies to the wholeSELECTlist, not to one column. Adding a column can only add rows.
Aggregates, GROUP BY, HAVING
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES
('hedge trimmer', 'garden', 950), ('cordless drill', 'power', 1200),
('wheelbarrow', 'garden', 500), ('tile cutter', 'power', 1500),
('hand saw', 'hand', 350), ('donated ladder', 'garden', NULL);
SELECT
count(*) AS rows_in_table, -- counts rows
count(deposit) AS values_present, -- counts non-NULLs
count(DISTINCT shelf) AS shelves,
sum(deposit) AS total,
round(avg(deposit)) AS mean, -- NULLs are skipped, not zeroed
min(deposit) AS cheapest,
max(deposit) AS dearest
FROM tools;
SELECT shelf, count(*) AS tools, sum(deposit) AS held, group_concat(name, ' + ') AS contents
FROM tools
GROUP BY shelf
HAVING count(*) >= 2
ORDER BY held DESC;
SELECT count(*) AS matched, sum(deposit) AS sum_is_null, COALESCE(sum(deposit), 0) AS safe
FROM tools WHERE deposit > 99999;rows_in_table | values_present | shelves | total | mean | cheapest | dearest
--------------+----------------+---------+-------+------+----------+--------
6 | 5 | 3 | 4500 | 900 | 350 | 1500
(1 row)
shelf | tools | held | contents
-------+-------+------+---------------------------------------------
power | 2 | 2700 | cordless drill + tile cutter
garden | 3 | 1450 | hedge trimmer + wheelbarrow + donated ladder
(2 rows)
matched | sum_is_null | safe
--------+-------------+-----
0 | NULL | 0
(1 row)- Every
SELECTcolumn must be grouped by or aggregated. SQLite lets you break that rule and returns a value from an arbitrary row; PostgreSQL and MySQL reject the query. This is the commonest portability trap. - Over no rows,
countis0butsumisNULL. Wrap it inCOALESCE, or use SQLite'stotal(). group_concat(x, sep)in SQLite isGROUP_CONCAT(x SEPARATOR sep)in MySQL andstring_agg(x, sep)in PostgreSQL.WHEREfilters rows before grouping;HAVINGfilters groups after. An aggregate inWHEREis an error.
Joins
CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT);
CREATE TABLE loans (id INTEGER PRIMARY KEY, member_id INTEGER, tool_id INTEGER, taken_on TEXT);
INSERT INTO members (id, name) VALUES (1, 'Asha'), (2, 'Bruno'), (3, 'Cleo');
INSERT INTO tools (id, name, shelf) VALUES (10, 'hedge trimmer', 'garden'), (11, 'tile cutter', 'power');
INSERT INTO loans (member_id, tool_id, taken_on) VALUES (1, 10, '2026-03-04'), (1, 11, '2026-03-21'), (2, 10, '2026-04-02');
-- INNER JOIN: only the pairs that match.
SELECT m.name AS member, t.name AS tool, l.taken_on
FROM loans AS l
JOIN members AS m ON m.id = l.member_id
JOIN tools AS t ON t.id = l.tool_id
ORDER BY l.taken_on;
-- LEFT JOIN: every left row survives; unmatched right columns come back NULL.
SELECT m.name, count(l.id) AS loans -- count(l.id), never count(*)
FROM members AS m
LEFT JOIN loans AS l ON l.member_id = m.id
GROUP BY m.id, m.name
ORDER BY loans DESC, m.name;
-- Anti-join: what is in A but not in B.
SELECT t.name AS never_borrowed
FROM tools AS t
LEFT JOIN loans AS l ON l.tool_id = t.id
WHERE l.id IS NULL;member | tool | taken_on
-------+---------------+-----------
Asha | hedge trimmer | 2026-03-04
Asha | tile cutter | 2026-03-21
Bruno | hedge trimmer | 2026-04-02
(3 rows)
name | loans
------+------
Asha | 2
Bruno | 1
Cleo | 0
(3 rows)
never_borrowed
--------------
(0 rows)- Rows disappear with an inner join: anything unmatched is dropped, silently.
- Rows duplicate when the right side has several matches, so summing a left-hand column double-counts it (fan-out). Aggregate the many-side in a CTE first, then join.
- With a
LEFT JOIN,count(*)gives unmatched rows a1. Count a right-hand column instead. - A condition in
ONdecides how rows pair; the same condition inWHEREturns aLEFT JOINback into an inner join. - Two tables separated by a comma with no
ONis a cross join — every pair. WriteCROSS JOINif you mean it. RIGHT JOINandFULL OUTER JOINonly exist in SQLite 3.39+. Rewriting aRIGHT JOINas aLEFT JOINwith the tables swapped is portable everywhere.
Subqueries and CTEs
CREATE TABLE loans (id INTEGER PRIMARY KEY, member_id INTEGER, deposit INTEGER);
INSERT INTO loans (member_id, deposit) VALUES (1, 950), (1, 1200), (1, 500), (2, 350), (3, 1500);
-- Scalar subquery: one row, one column, usable as a value.
SELECT count(*) AS above_average
FROM loans
WHERE deposit > (SELECT avg(deposit) FROM loans);
-- IN / NOT EXISTS with a subquery.
SELECT DISTINCT member_id FROM loans WHERE member_id IN (SELECT member_id FROM loans WHERE deposit >= 1200);
-- Derived table: a subquery in FROM, which must be named.
SELECT round(avg(n), 2) AS mean_loans
FROM (SELECT member_id, count(*) AS n FROM loans GROUP BY member_id) AS per_member;
-- The same thing as a CTE, plus a second step using the first.
WITH per_member AS (
SELECT member_id, count(*) AS loans, sum(deposit) AS value
FROM loans
GROUP BY member_id
),
busy AS (
SELECT * FROM per_member WHERE loans > 1
)
SELECT member_id, loans, value FROM busy;above_average
-------------
3
(1 row)
member_id
---------
1
3
(2 rows)
mean_loans
----------
1.67
(1 row)
member_id | loans | value
----------+-------+------
1 | 3 | 2650
(1 row)- A CTE lives only in the statement that defines it. The semicolon ends it; it is not a temporary table.
- A CTE can be referenced twice in one statement; a derived table cannot.
WITHgoes at the top of the statement, beforeSELECT, and one statement has oneWITHclause with its definitions comma-separated.NOT EXISTSbeatsNOT INwhenever the inner column can beNULL.- Recursive form:
WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM t WHERE n < 5) SELECT * FROM t. PostgreSQL requires the wordRECURSIVE; SQLite accepts it either way. MySQL needs 8.0+ for any CTE.
Window Functions
Available in SQLite 3.25+, PostgreSQL 9.0+, MySQL 8.0+.
CREATE TABLE takings (id INTEGER PRIMARY KEY, held_on TEXT, session TEXT, amount INTEGER);
INSERT INTO takings (held_on, session, amount) VALUES
('2026-03-07', 'repair', 4200), ('2026-03-14', 'sharpen', 1800),
('2026-03-21', 'repair', 3600), ('2026-03-28', 'sharpen', 1800);
SELECT
held_on,
session,
amount,
sum(amount) OVER () AS grand_total,
sum(amount) OVER (PARTITION BY session) AS session_total,
sum(amount) OVER (ORDER BY held_on
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
row_number() OVER (ORDER BY amount DESC, held_on) AS row_no,
rank() OVER (ORDER BY amount DESC) AS placing,
dense_rank() OVER (ORDER BY amount DESC) AS dense_placing,
lag(amount) OVER (ORDER BY held_on) AS previous,
lead(amount) OVER (ORDER BY held_on) AS next_one
FROM takings
ORDER BY held_on;held_on | session | amount | grand_total | session_total | running_total | row_no | placing | dense_placing | previous | next_one
-----------+---------+--------+-------------+---------------+---------------+--------+---------+---------------+----------+---------
2026-03-07 | repair | 4200 | 11400 | 7800 | 4200 | 1 | 1 | 1 | NULL | 1800
2026-03-14 | sharpen | 1800 | 11400 | 3600 | 6000 | 3 | 3 | 3 | 4200 | 3600
2026-03-21 | repair | 3600 | 11400 | 7800 | 9600 | 2 | 2 | 2 | 1800 | 1800
2026-03-28 | sharpen | 1800 | 11400 | 3600 | 11400 | 4 | 3 | 3 | 3600 | NULL
(4 rows)Top N per group, with the numbering in a CTE because a window function cannot go in WHERE:
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES
('hedge trimmer', 'garden', 950), ('wheelbarrow', 'garden', 500), ('post driver', 'garden', 1100),
('tile cutter', 'power', 1500), ('cordless drill', 'power', 1200);
WITH ranked AS (
SELECT shelf, name, deposit,
row_number() OVER (PARTITION BY shelf ORDER BY deposit DESC, name) AS place
FROM tools
)
SELECT shelf, place, name, deposit FROM ranked WHERE place <= 2 ORDER BY shelf, place;shelf | place | name | deposit
-------+-------+----------------+--------
garden | 1 | post driver | 1100
garden | 2 | hedge trimmer | 950
power | 1 | tile cutter | 1500
power | 2 | cordless drill | 1200
(4 rows)row_number()never ties;rank()ties and skips (1, 1, 3);dense_rank()ties without skipping (1, 1, 2).ORDER BYinsideOVERturns an aggregate into a running one.- The default frame is
RANGE ... CURRENT ROW, which includes every row sharing the currentORDER BYvalue. WriteROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWwhen the ordering column can repeat. - A sliding window:
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW(a three-row moving average). lag(col, n, default)andlead(col, n, default)supply a fallback for the ends.
CASE
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, deposit INTEGER);
INSERT INTO tools (name, deposit) VALUES
('hedge trimmer', 950), ('tile cutter', 1500), ('hand saw', 350), ('donated ladder', NULL);
SELECT
name,
deposit,
CASE
WHEN deposit IS NULL THEN 'not set'
WHEN deposit >= 1000 THEN 'high'
WHEN deposit >= 500 THEN 'medium'
ELSE 'low'
END AS band,
sum(CASE WHEN deposit >= 500 THEN 1 ELSE 0 END) OVER () AS count_of_500_plus
FROM tools
ORDER BY name;name | deposit | band | count_of_500_plus
---------------+---------+---------+------------------
donated ladder | NULL | not set | 2
hand saw | 350 | low | 2
hedge trimmer | 950 | medium | 2
tile cutter | 1500 | high | 2
(4 rows)The branches are tested in order, so put the NULL test first — without it, a NULL deposit falls through
every comparison and lands in ELSE. sum(CASE WHEN ... THEN 1 ELSE 0 END) is the standard way to count a
condition inside an aggregate.
INSERT, UPDATE, DELETE
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT NOT NULL, shelf TEXT, deposit INTEGER);
INSERT INTO tools (name, shelf, deposit) VALUES ('hedge trimmer', 'garden', 950); -- one row
INSERT INTO tools (name, shelf, deposit) VALUES
('cordless drill', 'power', 1200), ('wheelbarrow', 'garden', 500); -- many rows
CREATE TABLE garden (id INTEGER PRIMARY KEY, name TEXT, deposit INTEGER);
INSERT INTO garden (name, deposit) SELECT name, deposit FROM tools WHERE shelf = 'garden'; -- from a query
UPDATE tools SET deposit = deposit + 100 WHERE shelf = 'power' RETURNING name, deposit; -- SQLite 3.35+
SELECT changes() AS rows_changed;
DELETE FROM tools WHERE deposit < 600;
SELECT id, name, deposit FROM tools ORDER BY id;name | deposit
---------------+--------
cordless drill | 1300
(1 row)
rows_changed
------------
1
(1 row)
id | name | deposit
---+----------------+--------
1 | hedge trimmer | 950
2 | cordless drill | 1300
(2 rows)- Always name the columns in an
INSERT. Without the list, the values follow the physical column order and a new column breaks every statement silently. UPDATEandDELETEwith noWHEREhit every row, with no warning and no undo.- The habit: run the
SELECTwith the sameWHEREfirst, then the write, then theSELECTagain. DELETE FROM t WHERE returned_on = NULLdeletes nothing. UseIS NULL.INSERT OR IGNOREskips rows that would break a constraint;INSERT ... ON CONFLICT (col) DO UPDATE SET ...is SQLite's upsert (PostgreSQL uses the same syntax, MySQL usesON DUPLICATE KEY UPDATE).
Transactions
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT, deposit INTEGER);
INSERT INTO tools (name, deposit) VALUES ('hedge trimmer', 950), ('tile cutter', 1500);
BEGIN;
UPDATE tools SET deposit = 0;
SELECT name, deposit AS inside FROM tools ORDER BY id;
ROLLBACK;
SELECT name, deposit AS after_rollback FROM tools ORDER BY id;
BEGIN;
UPDATE tools SET deposit = deposit + 50 WHERE id = 1;
COMMIT;
SELECT name, deposit AS after_commit FROM tools ORDER BY id;name | inside
--------------+-------
hedge trimmer | 0
tile cutter | 0
(2 rows)
name | after_rollback
--------------+---------------
hedge trimmer | 950
tile cutter | 1500
(2 rows)
name | after_commit
--------------+-------------
hedge trimmer | 1000
tile cutter | 1500
(2 rows)BEGIN … COMMIT makes a group of statements all-or-nothing; ROLLBACK discards the group. A statement
outside a transaction is its own transaction. SAVEPOINT name and ROLLBACK TO name give a partial undo
inside a longer transaction.
Set Operations
CREATE TABLE spring (id INTEGER PRIMARY KEY, member TEXT);
CREATE TABLE summer (id INTEGER PRIMARY KEY, member TEXT);
INSERT INTO spring (member) VALUES ('Asha'), ('Bruno'), ('Cleo'), ('Asha');
INSERT INTO summer (member) VALUES ('Bruno'), ('Devi');
SELECT member FROM spring UNION SELECT member FROM summer ORDER BY member; -- both, duplicates removed
SELECT member FROM spring UNION ALL SELECT member FROM summer ORDER BY member; -- both, duplicates kept
SELECT member FROM spring INTERSECT SELECT member FROM summer; -- in both
SELECT member FROM spring EXCEPT SELECT member FROM summer ORDER BY member; -- in the first onlymember
------
Asha
Bruno
Cleo
Devi
(4 rows)
member
------
Asha
Asha
Bruno
Bruno
Cleo
Devi
(6 rows)
member
------
Bruno
(1 row)
member
------
Asha
Cleo
(2 rows)Both sides need the same number of columns, in the same order. UNION sorts and de-duplicates, so
UNION ALL is cheaper when you know there are no duplicates or do not mind them. PostgreSQL and MySQL use
EXCEPT and INTERSECT too, though MySQL only gained them in 8.0.31.
Text Functions
SELECT
length('hedge trimmer') AS len,
upper('garden') AS up,
lower('GARDEN') AS down,
substr('hedge trimmer', 1, 5) AS first_five, -- 1-based, not 0-based
substr('2026-04-02', 1, 7) AS year_month,
replace('hand saw', 'saw', 'plane') AS replaced,
trim(' padded ') AS trimmed,
instr('hedge trimmer', 'trim') AS position_or_zero,
'a' || '-' || 'b' AS joined,
printf('%.2f', 9.5) AS formatted,
cast('42' AS INTEGER) + 1 AS cast_then_add;len | up | down | first_five | year_month | replaced | trimmed | position_or_zero | joined | formatted | cast_then_add
----+--------+--------+------------+------------+------------+---------+------------------+--------+-----------+--------------
13 | GARDEN | garden | hedge | 2026-04 | hand plane | padded | 7 | a-b | 9.50 | 43
(1 row)SQLite has no LEFT() or RIGHT() — use substr(). String positions are 1-based everywhere in SQL, unlike
most programming languages. || is the standard concatenation operator (PostgreSQL agrees; MySQL needs
CONCAT()).
Numbers
SELECT
7 / 2 AS integer_division, -- 3, not 3.5
7 / 2.0 AS with_a_decimal,
7 * 1.0 / 2 AS forced_to_decimal,
CAST(7 AS REAL) / 2 AS cast_version,
7 % 2 AS remainder,
round(9.567, 2) AS rounded,
abs(-42) AS absolute,
max(3, 9) AS bigger_of_two, -- two arguments: not the aggregate
min(3, 9) AS smaller_of_two;integer_division | with_a_decimal | forced_to_decimal | cast_version | remainder | rounded | absolute | bigger_of_two | smaller_of_two
-----------------+----------------+-------------------+--------------+-----------+---------+----------+---------------+---------------
3 | 3.5 | 3.5 | 3.5 | 1 | 9.57 | 42 | 9 | 3
(1 row)Two integers divided give an integer in SQLite and PostgreSQL; MySQL returns 3.5. Make one side a
decimal, or CAST, so the query means the same thing everywhere. Store money as whole pence or cents in an
INTEGER column rather than as a REAL.
Dates and Times
SQLite has no date or time type. A date is TEXT in YYYY-MM-DD form (which sorts and compares
correctly), a Unix timestamp, or a Julian day number. These functions do the work:
SELECT
date('2026-04-02') AS normalised,
date('2026-04-02', '+7 days') AS a_week_later,
date('2026-04-02', 'start of month') AS month_start,
date('2026-04-02', 'start of month', '+1 month', '-1 day') AS month_end,
strftime('%Y-%m', '2026-04-02') AS year_month,
strftime('%w', '2026-04-02') AS day_of_week_0_sunday,
CAST(julianday('2026-04-11') - julianday('2026-04-02') AS INTEGER) AS days_between,
date('2026-04-02') < '2026-04-11' AS text_compare_works;normalised | a_week_later | month_start | month_end | year_month | day_of_week_0_sunday | days_between | text_compare_works
-----------+--------------+-------------+------------+------------+----------------------+--------------+-------------------
2026-04-02 | 2026-04-09 | 2026-04-01 | 2026-04-30 | 2026-04 | 4 | 9 | 1
(1 row)Because dates are just text, nothing stops a row holding 02/04/2026, and that row will sort and filter
wrongly with no error. Validate on the way in (CHECK (taken_on LIKE '____-__-__') catches the shape) and
keep one format. PostgreSQL and MySQL have real DATE types and would reject the bad value themselves.
Indexes and Query Plans
CREATE TABLE loans (id INTEGER PRIMARY KEY, member_id INTEGER, taken_on TEXT);
INSERT INTO loans (member_id, taken_on) VALUES (1, '2026-03-04'), (2, '2026-03-19'), (1, '2026-04-08');
CREATE INDEX idx_loans_member ON loans(member_id);
CREATE UNIQUE INDEX idx_loans_member_date ON loans(member_id, taken_on);
EXPLAIN QUERY PLAN SELECT * FROM loans WHERE member_id = 1;
SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'loans' ORDER BY name;id | parent | notused | detail
---+--------+---------+----------------------------------------------------------------------
2 | 0 | 56 | SEARCH loans USING COVERING INDEX idx_loans_member_date (member_id=?)
(1 row)
name
---------------------
idx_loans_member
idx_loans_member_date
(2 rows)An index makes a WHERE or JOIN on that column fast and makes every write slightly slower, so index the
columns you filter and join on, not every column. Read the plan's detail column: SEARCH ... USING INDEX
means an index was used, COVERING INDEX means the index held every column the query needed so the table
itself was never read, and SCAN means every row was examined. Note that the planner picked
idx_loans_member_date over idx_loans_member here — it chooses, you do not. EXPLAIN QUERY PLAN is
SQLite's spelling; PostgreSQL and MySQL both use EXPLAIN, and EXPLAIN ANALYZE to actually run the query
while measuring it.
SQLite vs PostgreSQL and MySQL, at a Glance
Each line is SQLite here, then elsewhere.
- Declared column types — an affinity only, unless the table is
STRICT; enforced by PostgreSQL and MySQL. - Foreign keys — not enforced unless
PRAGMA foreign_keys = ON; always enforced elsewhere. PRIMARY KEYon aTEXTcolumn — allowsNULLs; rejected elsewhere.- A
SELECTcolumn that is neither grouped nor aggregated — allowed, taken from an arbitrary row; rejected by PostgreSQL and by MySQL's defaults. LIKE— case-insensitive for ASCII; case-sensitive in PostgreSQL, whereILIKEis the insensitive version, and collation-dependent in MySQL.=on text — case-sensitive; case-insensitive under MySQL's default collation.- Date and time types — none, so dates are text or numbers; PostgreSQL and MySQL have real
DATE,TIMEandTIMESTAMPtypes. 7 / 2—3; also3in PostgreSQL, but3.5in MySQL.NULLS LAST— SQLite 3.30+; PostgreSQL yes, MySQL no.RIGHT JOINandFULL OUTER JOIN— SQLite 3.39+; long supported elsewhere.- Window functions — SQLite 3.25+; PostgreSQL 9.0+, MySQL 8.0+.
TRUNCATE TABLE— not available, useDELETE FROM; available elsewhere.- Text concatenation — the
||operator, as in PostgreSQL; MySQL needsCONCAT(). - Row limiting —
LIMIT n OFFSET k, same in PostgreSQL and MySQL; SQL Server usesTOPorOFFSET ... FETCH.
Work through the SQL lessons for the reasoning behind each of these, the exercise path to practise them, and the playground to try anything on this page.
Try any snippet with your own values in the SQL playground.