Skip to content

SQL cheat sheet

The whole language on one page, for coming back to SQL after a while. Every query here runs on the sample shop and was checked on PostgreSQL and SQLite; the line under each says so. Where the two differ, both are given. The chapter number at each heading leads to the full story.

The shape of a query (ch. 2, 3)

SELECT   columns, expressions, aggregates        -- 5. pick and compute the output columns
FROM     table JOIN table ON condition           -- 1. find and join the rows
WHERE    condition on single rows                -- 2. keep rows
GROUP BY columns                                 -- 3. make one group per value
HAVING   condition on groups                     -- 4. keep groups
ORDER BY columns [ASC|DESC]                      -- 6. sort
LIMIT    n OFFSET m                              -- 7. cut (SQL Server: TOP n / OFFSET ... FETCH)

The numbers are the order the database works in. That is why a SELECT alias can't be used in WHERE (it doesn't exist yet) but can be used in ORDER BY.

Filtering (ch. 2, 5, 6)

SELECT name, price
FROM products
WHERE price BETWEEN 10 AND 30          -- both ends included
  AND category IN ('Home', 'Office')
  AND name LIKE '%a%'                  -- % any run of characters, _ exactly one
  AND category IS NOT NULL             -- never "= NULL"
ORDER BY price DESC, name
LIMIT 3;

Ran on PostgreSQL 16 and SQLite 3.53: same rows on both (3 rows).

LIKE is case-sensitive in PostgreSQL (ILIKE ignores case) but not, for plain English letters, in SQLite.

NULL (ch. 6)

SELECT name,
       COALESCE(email, 'no email')   AS email_or_note,   -- first non-NULL
       NULLIF(city, 'Toronto')       AS city_not_toronto -- NULL when equal
FROM customers;

Ran on PostgreSQL 16 and SQLite 3.53: same rows on both (10 rows).

Any comparison with NULL is unknown, and WHERE keeps only true. x NOT IN (list with a NULL) is never true: use NOT EXISTS.

CASE (ch. 4)

SELECT name,
       CASE WHEN price >= 100 THEN 'high'
            WHEN price >= 20  THEN 'mid'
            ELSE 'low' END AS band
FROM products;

Ran on PostgreSQL 16 and SQLite 3.53: same rows on both (11 rows).

Aggregates (ch. 7)

SELECT department,
       COUNT(*)            AS people,     -- every row
       COUNT(salary)       AS paid,       -- rows where salary is not NULL
       SUM(salary)         AS payroll,
       ROUND(AVG(salary), 0) AS avg_salary, -- AVG skips NULLs
       MIN(hire_date)      AS first_hire
FROM employees
WHERE hire_date < '2026-01-01'           -- rows, before grouping
GROUP BY department
HAVING COUNT(*) >= 2                     -- groups, after grouping
ORDER BY payroll DESC;

Ran on PostgreSQL 16 and SQLite 3.53: same rows on both. 3 rows.

department people paid payroll avg_salary first_hire
Engineering 4 4 475000 118750 2020-01-15
Sales 4 4 315000 78750 2020-07-01
Support 2 2 127000 63500 2021-11-01

Every column in SELECT is either in GROUP BY or inside an aggregate. COUNT(DISTINCT x) counts different values. Conditional counts: SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END).

Joins (ch. 8, 9)

SELECT c.name, o.order_id
FROM customers c
LEFT JOIN orders o
       ON o.customer_id = c.customer_id
      AND o.status = 'shipped'           -- a filter on the right table goes in ON, or the LEFT JOIN turns INNER
ORDER BY c.customer_id, o.order_id;

Ran on PostgreSQL 16 and SQLite 3.53: same rows on both (16 rows).

Join Keeps
INNER JOIN only pairs that match
LEFT JOIN every left row; NULLs where no match
RIGHT JOIN every right row (SQLite has it since 3.39)
FULL JOIN every row from both (SQLite since 3.39; not in MySQL)
CROSS JOIN every pair

Rows with no match (anti join), the safe way:

SELECT c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);

Ran on PostgreSQL 16 and SQLite 3.53: same rows on both (1 row).

Subqueries and CTEs (ch. 10, 11)

WITH order_totals AS (
  SELECT order_id, SUM(quantity * unit_price) AS total
  FROM order_items
  GROUP BY order_id
)
SELECT order_id, total
FROM order_totals
WHERE total > (SELECT AVG(total) FROM order_totals)
ORDER BY total DESC;

Ran on PostgreSQL 16 and SQLite 3.53: same rows on both. 5 rows.

order_id total
112 181.00
102 149.00
120 139.00
109 65.00
106 56.50

Window functions (ch. 13, 14)

A window function computes over related rows without collapsing them into one.

SELECT name, department, salary,
       ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC, name) AS rn,  -- 1,2,3,4 always
       RANK()       OVER (PARTITION BY department ORDER BY salary DESC) AS rnk,        -- 1,2,2,4 on ties
       DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS drnk,       -- 1,2,2,3 on ties
       salary - LAG(salary) OVER (PARTITION BY department ORDER BY salary, name) AS up_from_prev,
       SUM(salary)  OVER (PARTITION BY department)                          AS dept_total
FROM employees
WHERE salary IS NOT NULL
ORDER BY department, salary DESC, name;

Ran on PostgreSQL 16 and SQLite 3.53: same rows on both. 11 rows.

name department salary rn rnk drnk up_from_prev dept_total
Tom Becker Engineering 140000 1 1 1 20000 475000
Priya Nair Engineering 120000 2 2 2 25000 475000
Sam Lee Engineering 120000 3 2 2 0 475000
Lucia Gomez Engineering 95000 4 4 3 NULL 475000
Maya Rossi Management 180000 1 1 1 NULL 180000
Omar Aziz Sales 110000 1 1 1 40000 315000
Nina Berg Sales 70000 2 2 2 5000 315000
Raj Patel Sales 70000 3 2 2 0 315000
Zoe Martin Sales 65000 4 4 3 NULL 315000
Kwame Mensah Support 75000 1 1 1 23000 127000
Ella Novak Support 52000 2 2 2 NULL 127000

ROW_NUMBER needs a tie-break (, name above) or tied rows get their numbers by luck. Top N per group: rank in a CTE, then WHERE rnk <= N outside (a window function can't go in WHERE).

Running total: SUM(x) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).

Set operations (ch. 12)

UNION (removes duplicates), UNION ALL (keeps them, faster), INTERSECT, EXCEPT (Oracle before 21c: MINUS). The column counts and types must line up.

Dates (ch. 16)

Task PostgreSQL SQLite
first day of the month date_trunc('month', d)::date date(d, 'start of month')
year, month as numbers EXTRACT(YEAR FROM d) CAST(strftime('%Y', d) AS INTEGER)
add 7 days d + 7 or d + INTERVAL '7 days' date(d, '+7 days')
days between d2 - d1 julianday(d2) - julianday(d1)
today CURRENT_DATE date('now')

Text (ch. 5)

Both: UPPER, LOWER, TRIM, LENGTH, REPLACE, SUBSTR(s, start, len), s1 || s2. PostgreSQL also has split_part, position, left, right, regexp_replace, string_agg; SQLite has instr and group_concat.

Changing data (ch. 17)

INSERT INTO t (a, b) VALUES (1, 'x'), (2, 'y');
UPDATE t SET b = 'z' WHERE a = 1;                      -- no WHERE = every row
DELETE FROM t WHERE a = 2;
INSERT INTO t (a, b) VALUES (1, 'x')
  ON CONFLICT (a) DO UPDATE SET b = excluded.b;        -- upsert: PostgreSQL and SQLite

The usual mistakes

Mistake Fix
WHERE col = NULL WHERE col IS NULL
NOT IN (subquery) where the subquery can return NULL NOT EXISTS
Filtering the right table of a LEFT JOIN in WHERE Move the condition into ON
SUM after joining to a "many" table Aggregate first, then join
7 / 2 expecting 3.5 7 * 1.0 / 2
LIMIT 1 for "the highest" when there are ties RANK() / DENSE_RANK(), or WHERE x = (SELECT MAX(x) ...)
Window function in WHERE Compute it in a CTE, filter outside
No ORDER BY when the order matters Always finish with ORDER BY