Skip to content

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.

SQL
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;
Output
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:

SQL
CREATE TABLE tools (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO tools (name) VALUES ('hand saw');
Output
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:

SELECTFROMJOIN ... ONWHEREGROUP BYHAVINGORDER BYLIMIT / 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.

  • WHERE cannot see a SELECT alias or an aggregate (SQLite allows the alias anyway; PostgreSQL does not).
  • HAVING can test aggregates, because the groups exist by then.
  • ORDER BY can use a SELECT alias, because the list has already run.
  • A window function cannot go in WHERE, GROUP BY or HAVING — 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.

SQL
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;
Output
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:

SQL
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);
Output
name | credits
-----+--------
Asha |       2
(1 row)

Error in the statement on line 4: CHECK constraint failed: credits >= 0

Other 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.

SQL
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');
Output
id | member_id | taken_on
---+-----------+-----------
 1 |         1 | 2026-03-04
(1 row)

Error in the statement on line 14: FOREIGN KEY constraint failed

Delete 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

SQL
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 SQLite
Output
id | 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.
  • AND OR NOTAND binds tighter than OR, 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 NULL and IS NOT NULL — the only tests that work on NULL.
SQL
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 row
Output
name
-------------
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

SQL
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;
Output
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 = NULL never matches. Use IS NULL.
  • WHERE col > 5 and WHERE col <= 5 do not add up to the whole table when col can be NULL.
  • col NOT IN (list containing NULL) returns no rows at all. Prefer NOT EXISTS.

DISTINCT, GROUP BY and UNION are the exceptions: they treat all NULLs as one value.

ORDER BY, LIMIT, DISTINCT

SQL
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;
Output
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 BY means no guaranteed order, however tidy the result looks today.
  • NULLS LAST works in SQLite 3.30+ and PostgreSQL; MySQL does not accept it. Portable version: ORDER BY deposit IS NULL, deposit.
  • LIMIT n OFFSET k is SQLite, PostgreSQL and MySQL. SQL Server uses TOP or OFFSET ... FETCH.
  • DISTINCT applies to the whole SELECT list, not to one column. Adding a column can only add rows.

Aggregates, GROUP BY, HAVING

SQL
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;
Output
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 SELECT column 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, count is 0 but sum is NULL. Wrap it in COALESCE, or use SQLite's total().
  • group_concat(x, sep) in SQLite is GROUP_CONCAT(x SEPARATOR sep) in MySQL and string_agg(x, sep) in PostgreSQL.
  • WHERE filters rows before grouping; HAVING filters groups after. An aggregate in WHERE is an error.

Joins

SQL
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;
Output
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 a 1. Count a right-hand column instead.
  • A condition in ON decides how rows pair; the same condition in WHERE turns a LEFT JOIN back into an inner join.
  • Two tables separated by a comma with no ON is a cross join — every pair. Write CROSS JOIN if you mean it.
  • RIGHT JOIN and FULL OUTER JOIN only exist in SQLite 3.39+. Rewriting a RIGHT JOIN as a LEFT JOIN with the tables swapped is portable everywhere.

Subqueries and CTEs

SQL
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;
Output
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.
  • WITH goes at the top of the statement, before SELECT, and one statement has one WITH clause with its definitions comma-separated.
  • NOT EXISTS beats NOT IN whenever the inner column can be NULL.
  • 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 word RECURSIVE; 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+.

SQL
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;
Output
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:

SQL
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;
Output
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 BY inside OVER turns an aggregate into a running one.
  • The default frame is RANGE ... CURRENT ROW, which includes every row sharing the current ORDER BY value. Write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW when the ordering column can repeat.
  • A sliding window: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW (a three-row moving average).
  • lag(col, n, default) and lead(col, n, default) supply a fallback for the ends.

CASE

SQL
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;
Output
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

SQL
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;
Output
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.
  • UPDATE and DELETE with no WHERE hit every row, with no warning and no undo.
  • The habit: run the SELECT with the same WHERE first, then the write, then the SELECT again.
  • DELETE FROM t WHERE returned_on = NULL deletes nothing. Use IS NULL.
  • INSERT OR IGNORE skips rows that would break a constraint; INSERT ... ON CONFLICT (col) DO UPDATE SET ... is SQLite's upsert (PostgreSQL uses the same syntax, MySQL uses ON DUPLICATE KEY UPDATE).

Transactions

SQL
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;
Output
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)

BEGINCOMMIT 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

SQL
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 only
Output
member
------
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

SQL
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;
Output
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

SQL
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;
Output
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:

SQL
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;
Output
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

SQL
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;
Output
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 KEY on a TEXT column — allows NULLs; rejected elsewhere.
  • A SELECT column 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, where ILIKE is 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, TIME and TIMESTAMP types.
  • 7 / 23; also 3 in PostgreSQL, but 3.5 in MySQL.
  • NULLS LAST — SQLite 3.30+; PostgreSQL yes, MySQL no.
  • RIGHT JOIN and FULL OUTER JOIN — SQLite 3.39+; long supported elsewhere.
  • Window functions — SQLite 3.25+; PostgreSQL 9.0+, MySQL 8.0+.
  • TRUNCATE TABLE — not available, use DELETE FROM; available elsewhere.
  • Text concatenation — the || operator, as in PostgreSQL; MySQL needs CONCAT().
  • Row limitingLIMIT n OFFSET k, same in PostgreSQL and MySQL; SQL Server uses TOP or OFFSET ... 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.