Skip to main content

SQL cheatsheet

Learn SQL step by step, from your first SELECT to reading query plans. Each section has three parts:

  • In short โ€” the idea in one sentence.
  • Example โ€” queries on one small practice database, with the result in a comment.
  • Try it โ€” a tiny exercise to check you understood.

Want the longer story behind a topic? The complete guide walks through it in more depth.

๐Ÿ“– Full guide: SQL โ†’ Beginner
How to use this page

First create the practice database โ€” every example runs against it. Then go through Part 1 in order; each section builds on the one before. Move to Part 2 once you're comfortable, and Part 3 when you start caring about speed and safety. Type the queries yourself instead of copying them.

The examples use PostgreSQL, the most widely used free database. Almost everything also runs on SQLite and MySQL. Where a section is PostgreSQL-only, it says so, and the Quick Reference shows the other databases' spelling.

Contentsโ€‹

Practice database

Part 1 โ€” Beginner: What SQL is ยท Choosing rows ยท Sorting & limiting ยท Text, numbers & NULL ยท Counting & grouping ยท Joining tables ยท Changing data ยท Creating tables

Part 2 โ€” Core: Subqueries ยท CTEs ยท CASE ยท Window functions ยท Combining results ยท Dates & times ยท Indexes ยท Transactions ยท Views ยท Insert or update (upsert) ยท Common mistakes

Part 3 โ€” Advanced: Reading query plans ยท Isolation & locking ยท Recursive queries ยท JSON columns ยท Designing tables ยท SQL from code ยท Words you'll meet

Practice databaseโ€‹

In short: a tiny shop โ€” customers place orders, and each order has items (products and quantities). Run this once, then try every example on it.

-- Practice database: a tiny shop
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE,
city TEXT,
joined_on DATE NOT NULL
);

CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL
);

CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers (id),
ordered_at DATE NOT NULL,
status TEXT NOT NULL
);

CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders (id),
product_id INTEGER NOT NULL REFERENCES products (id),
quantity INTEGER NOT NULL,
PRIMARY KEY (order_id, product_id)
);

INSERT INTO customers (id, name, email, city, joined_on) VALUES
(1, 'Ada', 'ada@example.com', 'London', '2024-01-15'),
(2, 'Bo', 'bo@example.com', 'Paris', '2024-03-02'),
(3, 'Cy', 'cy@example.com', 'London', '2025-06-20'),
(4, 'Di', NULL, 'Berlin', '2025-11-05'),
(5, 'Ed', 'ed@example.com', NULL, '2026-02-10');

INSERT INTO products (id, name, category, price) VALUES
(1, 'Keyboard', 'electronics', 49.99),
(2, 'Mouse', 'electronics', 19.99),
(3, 'Desk', 'furniture', 199.00),
(4, 'Lamp', 'furniture', 35.50),
(5, 'Notebook', 'stationery', 3.25);

INSERT INTO orders (id, customer_id, ordered_at, status) VALUES
(101, 1, '2026-01-05', 'delivered'),
(102, 1, '2026-02-11', 'delivered'),
(103, 2, '2026-02-14', 'shipped'),
(104, 3, '2026-03-01', 'cancelled'),
(105, 1, '2026-03-20', 'pending'),
(106, 4, '2026-03-22', 'delivered');

INSERT INTO order_items (order_id, product_id, quantity) VALUES
(101, 1, 1), (101, 2, 2),
(102, 3, 1),
(103, 2, 1), (103, 4, 2),
(104, 1, 1),
(105, 4, 1),
(106, 3, 1), (106, 1, 1);

Where to run it: install PostgreSQL and open psql; or run sqlite3 shop.db (already installed on macOS and most Linux); or paste it into an online playground such as DB Fiddle. Worth noticing: Di has no email, Ed has no city and no orders, and nobody has ever bought a Notebook. Those gaps appear in many examples.

Part 1 โ€” Beginnerโ€‹

1. What SQL isโ€‹

In short: SQL (Structured Query Language) is how you ask a relational database for data. Data lives in tables โ€” rows and columns, like a spreadsheet with strict rules.

WordMeaningIn the practice database
TableA set of rows with the same columnscustomers, orders
RowOne recordAda's customer record
ColumnOne field, with a fixed typeemail (text), price (number)
Primary keyThe column(s) that identify each row uniquelycustomers.id
Foreign keyA column pointing at another table's primary keyorders.customer_id โ†’ customers.id
NULL"No value" โ€” unknown or missingDi's email
SELECT name, city FROM customers;   -- 5 rows: Ada London, Bo Paris, Cy London, Di Berlin, Ed (no city)

SQL says what you want, not how to get it โ€” the database works out the fastest way. Keywords aren't case-sensitive (select = SELECT), but writing them in capitals makes queries easier to read. Text values go in single quotes: 'London'.

Try it: list every product's name and price.

2. Choosing rowsโ€‹

In short: SELECT picks the columns, FROM picks the table, and WHERE keeps only the rows that match a condition.

SELECT * FROM products WHERE price < 40;
-- Mouse 19.99, Lamp 35.50, Notebook 3.25 (* = every column)

SELECT name FROM customers
WHERE city = 'London' AND joined_on >= '2025-01-01';
-- Cy

SELECT name FROM products WHERE category IN ('furniture', 'stationery');
-- Desk, Lamp, Notebook

SELECT name FROM products WHERE price BETWEEN 20 AND 50;
-- Keyboard, Lamp (BETWEEN includes both ends)

SELECT name FROM customers WHERE email LIKE '%@example.com';
-- Ada, Bo, Cy, Ed (% = any text, _ = one character)

SELECT DISTINCT city FROM customers;
-- London, Paris, Berlin, NULL (each value once)

Rows may come back in a different order on your screen โ€” without ORDER BY (next section), the database returns them in whatever order is fastest.

Combine conditions with AND, OR, and NOT. Use brackets when you mix them: WHERE (city = 'London' OR city = 'Paris') AND joined_on >= '2025-01-01'.

Try it: find the orders with status 'delivered' placed in February 2026.

3. Sorting & limitingโ€‹

In short: ORDER BY sorts the result; LIMIT keeps only the first few rows. Without ORDER BY, row order is not guaranteed.

SELECT name, price FROM products ORDER BY price DESC;
-- Desk 199.00, Keyboard 49.99, Lamp 35.50, Mouse 19.99, Notebook 3.25

SELECT name, category, price FROM products ORDER BY category, price DESC;
-- electronics: Keyboard, Mouse ยท furniture: Desk, Lamp ยท stationery: Notebook

SELECT name FROM products ORDER BY price DESC LIMIT 2;
-- Desk, Keyboard (top 2)

SELECT name FROM products ORDER BY price DESC LIMIT 2 OFFSET 2;
-- Lamp, Mouse (the next 2: "page 2")

SELECT name AS product, price * 1.2 AS price_with_tax
FROM products
ORDER BY price_with_tax DESC
LIMIT 1;
-- Desk 238.800 (AS gives a column a new name)

ASC (smallest first) is the default; DESC means largest first.

Try it: show the three most recent orders, newest first.

4. Text, numbers & NULLโ€‹

In short: SQL has functions for text and maths, and a special value, NULL, that means "unknown". Anything compared with NULL is unknown too, so use IS NULL.

SELECT UPPER(name), LENGTH(name), name || ' <' || email || '>' AS contact
FROM customers WHERE id = 1;
-- ADA, 3, Ada <ada@example.com> (|| joins text)

SELECT 7 / 2, 7 / 2.0, 7 % 2, ROUND(19.987, 2);
-- 3, 3.5, 1, 19.99 (whole / whole drops the decimals)

SELECT name FROM customers WHERE email IS NULL;
-- Di

SELECT name, COALESCE(city, 'unknown') AS city FROM customers WHERE id = 5;
-- Ed, unknown (COALESCE = first non-NULL value)

SELECT COUNT(*) FROM customers WHERE city = NULL;
-- 0 โ€” "= NULL" is never true; write IS NULL

Try it: list every customer with their email, showing 'no email' when it's missing.

5. Counting & groupingโ€‹

In short: aggregate functions (COUNT, SUM, AVG, MIN, MAX) turn many rows into one value. GROUP BY does that once per group.

SELECT COUNT(*) AS customers, COUNT(email) AS with_email FROM customers;
-- 5, 4 (COUNT(column) skips NULLs)

SELECT MIN(price), MAX(price), ROUND(AVG(price), 2) FROM products;
-- 3.25, 199.00, 61.55

SELECT category, COUNT(*) AS products, SUM(price) AS total
FROM products
GROUP BY category
ORDER BY category;
-- electronics 2 69.98 ยท furniture 2 234.50 ยท stationery 1 3.25

SELECT customer_id, COUNT(*) AS orders
FROM orders
GROUP BY customer_id
HAVING COUNT(*) >= 2;
-- 1, 3 (only Ada has 2 or more)

SELECT status, COUNT(*)
FROM orders
WHERE ordered_at >= '2026-02-01' -- WHERE filters rows before grouping
GROUP BY status
ORDER BY status;
-- cancelled 1, delivered 2, pending 1, shipped 1

The rule: every column in SELECT must either be in GROUP BY or inside an aggregate. WHERE filters rows before grouping; HAVING filters groups after.

Try it: count how many orders each status has, most common first.

6. Joining tablesโ€‹

In short: a join puts rows from two tables side by side where a condition matches โ€” usually a foreign key equal to a primary key.

SELECT o.id, c.name, o.status
FROM orders o -- o and c are short names ("aliases")
JOIN customers c ON c.id = o.customer_id
ORDER BY o.id;
-- 101 Ada delivered, 102 Ada delivered, 103 Bo shipped,
-- 104 Cy cancelled, 105 Ada pending, 106 Di delivered

SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id -- keep every customer
WHERE o.id IS NULL; -- ...who has no matching order
-- Ed

SELECT o.id, SUM(p.price * oi.quantity) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY o.id
ORDER BY o.id;
-- 101 89.97, 102 199.00, 103 90.99, 104 49.99, 105 35.50, 106 248.99
JoinKeeps
JOIN (= INNER JOIN)Only rows that match on both sides
LEFT JOINEvery row from the left table; NULLs where the right has no match
RIGHT JOINEvery row from the right table (rarely used โ€” swap the tables and use LEFT)
FULL JOINEvery row from both sides
CROSS JOINEvery combination of rows (5 ร— 5 = 25)

Try it: find the product nobody has ever ordered.

7. Changing dataโ€‹

In short: INSERT adds rows, UPDATE changes them, DELETE removes them. Always write the WHERE first โ€” without it, every row is changed.

INSERT INTO customers (id, name, email, city, joined_on)
VALUES (6, 'Flo', 'flo@example.com', 'Rome', '2026-09-01');

UPDATE products
SET price = price * 0.9
WHERE category = 'furniture'
RETURNING name, price;
-- Desk 179.10, Lamp 31.95 (RETURNING shows what changed)

DELETE FROM customers WHERE id = 5; -- Ed has no orders, so this is allowed

Safe habit: run a SELECT with the same WHERE first, check the rows, then change SELECT * to UPDATE or DELETE. Deleting a customer who has orders fails โ€” the foreign key protects the orders from pointing at nobody.

Try it: mark order 105 as 'shipped', then check it with a SELECT.

8. Creating tablesโ€‹

In short: CREATE TABLE defines the columns, their types, and the constraints โ€” rules the database enforces on every change.

CREATE TABLE reviews (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- numbered for you (PostgreSQL)
product_id INTEGER NOT NULL REFERENCES products (id), -- must be a real product
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5), -- rejects 0 or 6
body TEXT, -- optional
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- filled in if you leave it out
);

INSERT INTO reviews (product_id, rating, body) VALUES (3, 5, 'Solid desk');

ALTER TABLE customers ADD COLUMN phone TEXT; -- add a column
DROP TABLE reviews; -- delete the table and its data
ConstraintRule
PRIMARY KEYUnique and never NULL โ€” identifies the row
NOT NULLMust have a value
UNIQUENo two rows share this value
REFERENCES (foreign key)Must match a row in another table
CHECK (...)Must pass this condition
DEFAULT ...Value used when none is given

Auto-numbered ids: GENERATED ALWAYS AS IDENTITY in PostgreSQL, INTEGER PRIMARY KEY in SQLite, AUTO_INCREMENT in MySQL.

Try it: create a wishlist table (customer, product, date added) where a customer can't add the same product twice.

Part 2 โ€” Coreโ€‹

9. Subqueriesโ€‹

In short: a subquery is a query inside another query โ€” used as a value, a list, or a yes/no test.

SELECT name, price FROM products
WHERE price > (SELECT AVG(price) FROM products); -- a single value: 61.546
-- Desk 199.00

SELECT name FROM customers
WHERE id IN (SELECT customer_id FROM orders) -- a list of ids
ORDER BY name;
-- Ada, Bo, Cy, Di

SELECT c.name FROM customers c
WHERE EXISTS ( -- "is there at least one row?"
SELECT 1
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.customer_id = c.id AND p.category = 'furniture'
)
ORDER BY c.name;
-- Ada, Bo, Di (customers who bought furniture)

EXISTS stops at the first match, so it's usually the clearest and fastest way to ask "has this customer everโ€ฆ?".

Try it: list the products that cost more than the Keyboard.

10. CTEsโ€‹

In short: a CTE (Common Table Expression) names a subquery with WITH, so a long query reads top to bottom in steps.

WITH order_totals AS (                              -- step 1: total per order
SELECT o.id, o.customer_id, o.status, SUM(p.price * oi.quantity) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY o.id, o.customer_id, o.status
)
SELECT c.name, COUNT(*) AS orders, SUM(t.total) AS spent -- step 2: total per customer
FROM order_totals t
JOIN customers c ON c.id = t.customer_id
WHERE t.status <> 'cancelled'
GROUP BY c.name
ORDER BY spent DESC;
-- Ada 3 324.47 ยท Di 1 248.99 ยท Bo 1 90.99

A query can have several CTEs separated by commas: WITH a AS (...), b AS (SELECT ... FROM a) SELECT ... FROM b.

Try it: add a second CTE that keeps only customers who spent over 100, and select from it.

11. CASEโ€‹

In short: CASE is SQL's if / else โ€” it picks a value per row.

SELECT name, price,
CASE
WHEN price >= 100 THEN 'premium'
WHEN price >= 20 THEN 'standard'
ELSE 'budget'
END AS tier
FROM products
ORDER BY price DESC;
-- Desk premium, Keyboard standard, Lamp standard, Mouse budget, Notebook budget

SELECT
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) AS delivered,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM orders;
-- 3, 1 ("conditional counting": many counts in one pass)

Try it: label each customer 'new' if they joined in 2026, otherwise 'existing'.

12. Window functionsโ€‹

In short: a window function calculates across related rows โ€” a rank, a running total, the previous row โ€” without collapsing them the way GROUP BY does.

SELECT name, category, price,
RANK() OVER (PARTITION BY category ORDER BY price DESC) AS rank_in_category
FROM products
ORDER BY category, rank_in_category;
-- Keyboard 1, Mouse 2 ยท Desk 1, Lamp 2 ยท Notebook 1

SELECT id, ordered_at,
LAG(ordered_at) OVER (ORDER BY ordered_at) AS previous_order -- the row before
FROM orders
WHERE customer_id = 1;
-- 101 2026-01-05 NULL ยท 102 2026-02-11 2026-01-05 ยท 105 2026-03-20 2026-02-11

SELECT customer_id, id, ordered_at
FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ordered_at DESC) AS rn
FROM orders o
) ranked
WHERE rn = 1 -- newest order per customer
ORDER BY customer_id;
-- 1 105 ยท 2 103 ยท 3 104 ยท 4 106

PARTITION BY = "restart for each group"; ORDER BY inside OVER = the order to walk through the rows.

FunctionGives
ROW_NUMBER()1, 2, 3, 4 โ€” always unique
RANK()1, 2, 2, 4 โ€” ties share a rank, then a gap
DENSE_RANK()1, 2, 2, 3 โ€” ties share, no gap
LAG(col) / LEAD(col)The previous / next row's value
SUM(col) OVER (ORDER BY โ€ฆ)A running total

Try it: show each order of customer 1 with a running count of their orders.

13. Combining resultsโ€‹

In short: UNION, INTERSECT, and EXCEPT combine the rows of two queries that return the same columns.

SELECT name FROM customers WHERE city = 'London'
UNION -- both lists, duplicates removed
SELECT c.name FROM customers c JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'pending'
ORDER BY name; -- sorts the combined result
-- Ada, Cy (UNION ALL would keep Ada twice)

SELECT id FROM customers
EXCEPT -- in the first, not the second
SELECT customer_id FROM orders;
-- 5

SELECT customer_id FROM orders WHERE status = 'delivered'
INTERSECT -- in both
SELECT customer_id FROM orders WHERE status = 'pending';
-- 1

UNION ALL is faster than UNION because it skips removing duplicates โ€” use it when duplicates are impossible or wanted. EXCEPT is how testers diff two tables (see SQL for SDET).

Try it: list the categories that have been ordered, using INTERSECT.

14. Dates & timesโ€‹

In short: dates have their own type and functions. This section is PostgreSQL โ€” date functions are the part of SQL that differs most between databases.

SELECT CURRENT_DATE, CURRENT_TIMESTAMP;             -- today; now, with the time

SELECT id, EXTRACT(MONTH FROM ordered_at) AS month -- one part of a date
FROM orders WHERE id = 101;
-- 101, 1

SELECT (ordered_at + INTERVAL '30 days')::date AS due -- ::date turns it back into a date
FROM orders WHERE id = 101;
-- 2026-02-04

SELECT DATE_TRUNC('month', ordered_at)::date AS month, COUNT(*) AS orders
FROM orders
GROUP BY 1 -- 1 = the first column in SELECT
ORDER BY 1;
-- 2026-01-01 1 ยท 2026-02-01 2 ยท 2026-03-01 3

SELECT name, CURRENT_DATE - joined_on AS days_as_customer FROM customers;
-- one row per customer; date โˆ’ date = a number of days

Store moments in time as TIMESTAMPTZ (timestamp with time zone) so everyone reads the same instant, whatever their time zone. For SQLite, MySQL, and SQL Server spellings, see the dialect table.

Try it: count the orders placed on a weekend (EXTRACT(ISODOW FROM โ€ฆ) is 6 or 7).

15. Indexesโ€‹

In short: an index is a sorted lookup structure on one or more columns โ€” like a book's index โ€” so the database finds rows without reading the whole table.

CREATE INDEX idx_orders_customer_id ON orders (customer_id);           -- speeds up joins and filters
CREATE INDEX idx_orders_status_date ON orders (status, ordered_at); -- for WHERE status = โ€ฆ AND ordered_at โ€ฆ
CREATE UNIQUE INDEX idx_customers_email_lower ON customers (LOWER(email)); -- no duplicates, ignoring case

DROP INDEX idx_orders_status_date;
  • Index the columns you filter, join, or sort on often โ€” foreign keys first.
  • A two-column index (status, ordered_at) helps WHERE status = โ€ฆ and WHERE status = โ€ฆ AND ordered_at โ€ฆ, but not WHERE ordered_at โ€ฆ alone โ€” the first column matters most.
  • Every index slows down INSERT/UPDATE/DELETE a little and uses disk, so don't index everything. Primary keys and UNIQUE columns get one automatically.

Check whether a query uses an index with EXPLAIN โ€” see Reading query plans.

Try it: which index would help "all orders for product 3"? Create it.

16. Transactionsโ€‹

In short: a transaction groups changes so they all happen or none do. COMMIT saves them; ROLLBACK undoes them.

BEGIN;
INSERT INTO orders (id, customer_id, ordered_at, status) VALUES (107, 2, '2026-04-01', 'pending');
INSERT INTO order_items (order_id, product_id, quantity) VALUES (107, 5, 10);
COMMIT; -- the order and its item appear together

BEGIN;
UPDATE products SET price = 0 WHERE id = 3; -- oops
ROLLBACK; -- undone
SELECT price FROM products WHERE id = 3;
-- 199.00

Transactions give the ACID guarantees:

LetterMeans
AtomicAll the changes happen, or none do
ConsistentConstraints hold before and after
IsolatedOther sessions don't see half-finished changes
DurableOnce committed, it survives a crash

Without BEGIN, each statement is its own transaction ("autocommit").

Try it: start a transaction, delete order 105's items and the order, look at the orders table, then roll back and look again.

17. Viewsโ€‹

In short: a view is a saved query you can select from like a table. It stores the query, not the data, so it's always up to date.

CREATE VIEW order_totals AS
SELECT o.id, o.customer_id, o.status, SUM(p.price * oi.quantity) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY o.id, o.customer_id, o.status;

SELECT id, total FROM order_totals WHERE total > 100 ORDER BY total DESC;
-- 106 248.99, 102 199.00

Use views to give a complicated query a name, or to show other teams only some columns. A materialized view (PostgreSQL) stores the result for speed, and you refresh it yourself.

Try it: create a view customer_spend (name, total spent) on top of order_totals.

18. Insert or update (upsert)โ€‹

In short: an upsert inserts a row, or updates it if the key already exists โ€” in one safe statement.

INSERT INTO products (id, name, category, price)
VALUES (2, 'Mouse', 'electronics', 17.99)
ON CONFLICT (id) DO UPDATE SET price = EXCLUDED.price; -- EXCLUDED = the row you tried to insert

SELECT price FROM products WHERE id = 2;
-- 17.99

INSERT INTO products (id, name, category, price)
VALUES (1, 'Keyboard', 'electronics', 0)
ON CONFLICT (id) DO NOTHING; -- keep the existing row

This works in PostgreSQL and SQLite. MySQL writes it INSERT โ€ฆ ON DUPLICATE KEY UPDATE price = VALUES(price); SQL Server uses MERGE.

Try it: upsert a customer by email โ€” you'll need ON CONFLICT (email), which works because email is UNIQUE.

19. Common mistakesโ€‹

In short: the classic SQL traps, and how to avoid each one.

-- 1. "= NULL" is never true
SELECT COUNT(*) FROM customers WHERE city = NULL; -- 0
SELECT COUNT(*) FROM customers WHERE city IS NULL; -- 1

-- 2. NOT IN with a NULL in the list returns nothing
SELECT name FROM customers WHERE city NOT IN ('Paris', NULL); -- no rows!
SELECT name FROM customers WHERE city <> 'Paris' OR city IS NULL;
-- Ada, Cy, Di, Ed

-- 3. Whole-number division
SELECT 1 / 2, 1 / 2.0; -- 0, 0.5

-- 4. A WHERE on the right table turns a LEFT JOIN into an inner join
SELECT c.name, o.id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'delivered'; -- Bo, Cy and Ed vanish
SELECT c.name, o.id FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'delivered'
ORDER BY c.name, o.id;
-- Ada 101, Ada 102, Bo NULL, Cy NULL, Di 106, Ed NULL (condition moved into ON)

-- 5. COUNT(column) skips NULLs
SELECT COUNT(*), COUNT(city) FROM customers; -- 5, 4

-- 6. UPDATE or DELETE without WHERE changes every row
-- DELETE FROM order_items; -- all gone

Also: never rely on row order without ORDER BY, and never build SQL by gluing user input into a string (see SQL from code).

Try it: run query 2 with NOT IN ('Paris') (no NULL). Why does Ed still not appear?

Part 3 โ€” Advancedโ€‹

20. Reading query plansโ€‹

In short: EXPLAIN shows how the database plans to run a query; EXPLAIN ANALYZE runs it and shows the real time and row counts.

EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 1;
Seq Scan on orders  (cost=0.00..24.12 rows=6 width=44) (actual time=0.014..0.019 rows=3.00 loops=1)
Filter: (customer_id = 1)
Rows Removed by Filter: 3
Buffers: shared hit=1
Planning Time: 0.036 ms
Execution Time: 0.053 ms
You seeIt means
Seq ScanReads every row. Fine for tiny tables; slow for big ones
Index Scan / Index Only ScanUses an index to jump to the rows
Nested Loop / Hash Join / Merge JoinHow two tables are joined
cost=โ€ฆThe planner's estimate (in its own units) โ€” compare, don't read literally
rows= estimated vs actual โ€ฆ rows=Here 6 estimated vs 3 real. Big differences mean stale statistics โ€” run ANALYZE orders
Buffers: shared hit=1Pages read from memory (PostgreSQL 18 shows this by default)

On a table this small, PostgreSQL chooses a sequential scan even when an index exists, because reading one page is cheapest. Plans only become interesting with thousands of rows. SQLite uses EXPLAIN QUERY PLAN; MySQL uses EXPLAIN.

21. Isolation & lockingโ€‹

In short: when two sessions change the same rows at once, the isolation level decides what each one sees, and locks make one wait for the other.

BEGIN;
SELECT price FROM products WHERE id = 3 FOR UPDATE; -- lock this row until COMMIT (PostgreSQL, MySQL)
UPDATE products SET price = price - 10 WHERE id = 3;
COMMIT;

UPDATE products SET price = price - 10 WHERE id = 3; -- one statement: read and write together, safe

The classic bug is the lost update: two sessions both read price = 199, both write 199 - 10, and one discount disappears. Fix it by doing the maths in one UPDATE (above), or by locking the row with SELECT โ€ฆ FOR UPDATE.

LevelStopsCost
READ COMMITTED (PostgreSQL default)Reading uncommitted changesLow
REPEATABLE READ (MySQL default)Rows changing between two reads in one transactionMedium
SERIALIZABLEEvery anomaly โ€” as if transactions ran one at a timeHighest; some transactions must be retried

A deadlock is two transactions each waiting for a lock the other holds; the database kills one. Avoid it by always updating rows in the same order.

22. Recursive queriesโ€‹

In short: WITH RECURSIVE repeats a query on its own output โ€” for trees (managers โ†’ reports, folders, categories) and sequences.

WITH RECURSIVE counter(n) AS (
SELECT 1 -- start
UNION ALL
SELECT n + 1 FROM counter WHERE n < 5 -- repeat until the WHERE stops it
)
SELECT n FROM counter;
-- 1, 2, 3, 4, 5

CREATE TABLE staff (id INTEGER PRIMARY KEY, name TEXT, manager_id INTEGER REFERENCES staff (id));
INSERT INTO staff VALUES (1, 'Grace', NULL), (2, 'Alan', 1), (3, 'Linus', 2), (4, 'Ken', 2);

WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 0 AS depth FROM staff WHERE id = 1 -- the top of the tree
UNION ALL
SELECT s.id, s.name, s.manager_id, c.depth + 1
FROM staff s JOIN chain c ON s.manager_id = c.id -- everyone reporting to someone found
)
SELECT name, depth FROM chain ORDER BY depth, name;
-- Grace 0, Alan 1, Ken 2, Linus 2

Always make sure the recursive part eventually returns no rows, or the query never ends.

23. JSON columnsโ€‹

In short: a JSONB column (PostgreSQL) stores flexible JSON data that you can still filter and index. Use it for attributes that vary between rows, not to avoid designing tables.

ALTER TABLE products ADD COLUMN attrs JSONB;
UPDATE products SET attrs = '{"color": "black", "wireless": true}' WHERE id = 2;
UPDATE products SET attrs = '{"color": "white", "wireless": false}' WHERE id = 1;

SELECT name, attrs ->> 'color' AS color -- ->> gives text, -> gives JSON
FROM products
WHERE attrs @> '{"wireless": true}'; -- @> = "contains"
-- Mouse, black

CREATE INDEX idx_products_attrs ON products USING GIN (attrs); -- makes @> fast

SQLite and MySQL have JSON_EXTRACT(attrs, '$.color') instead.

24. Designing tablesโ€‹

In short: normalisation means storing each fact exactly once, so it can't get out of sync. The practice database is normalised: a customer's city is stored once, not copied onto every order.

Not normalised โ€” one wide table:
order_id | customer_name | customer_city | product_names
101 | Ada | London | Keyboard, Mouse โ† a list in one cell
102 | Ada | London | Desk โ† Ada's city copied again

Normalised โ€” four tables joined by keys (the practice database):
customers(id, name, city) orders(id, customer_id, โ€ฆ)
products(id, name, price) order_items(order_id, product_id, quantity)
RuleIn plain words
1NF (first normal form)One value per cell โ€” no lists, no phone1, phone2, phone3 columns
2NFEvery column depends on the whole key (a product's price belongs in products, not order_items)
3NFColumns depend only on the key, not on other columns (store city_id, look the country up from the city)

When to break the rules ("denormalise"): reporting tables and caches where read speed matters more โ€” for example, saving the price paid on order_items, because the product's price may change later. That's a different fact ("price at the time"), so it's still correct.

25. SQL from codeโ€‹

In short: always pass values as parameters, never by joining strings. Joining strings lets a user's input become part of your SQL โ€” SQL injection, one of the most common security holes.

import sqlite3

db = sqlite3.connect("shop.db")
city = "London' OR '1'='1" # hostile input

# BAD: the input becomes SQL โ€” this returns every customer
# db.execute(f"SELECT name FROM customers WHERE city = '{city}'")

# GOOD: the ? placeholder sends city as a plain value
rows = db.execute("SELECT name FROM customers WHERE city = ?", (city,)).fetchall()
print(rows) # [] โ€” no city has that odd name
// Java (JDBC): the same idea with PreparedStatement
static List<String> customersIn(java.sql.Connection db, String city) throws java.sql.SQLException {
var ps = db.prepareStatement("SELECT name FROM customers WHERE city = ?");
ps.setString(1, city); // sent as a value, never as SQL
var names = new ArrayList<String>();
try (var rs = ps.executeQuery()) {
while (rs.next()) names.add(rs.getString("name"));
}
return names;
}

Placeholders differ by library: ? (SQLite, JDBC), %s (psycopg, Python's PostgreSQL driver), :name (SQLAlchemy). An ORM (Object-Relational Mapper, like Hibernate or SQLAlchemy) writes most SQL for you โ€” but you still need to read the SQL it produces when something is slow.

26. Words you'll meetโ€‹

In short: the jargon, in one line each.

WordMeaning
RDBMSRelational Database Management System โ€” PostgreSQL, MySQL, SQL Server, Oracle, SQLite
DDLData Definition Language โ€” CREATE, ALTER, DROP (the structure)
DMLData Manipulation Language โ€” SELECT, INSERT, UPDATE, DELETE (the data)
CRUDCreate, Read, Update, Delete โ€” the four basic operations
SchemaThe set of tables and their columns; in PostgreSQL also a named folder of tables
Query planThe steps the database chose to run your query
CardinalityHow many distinct values a column has (high = good for an index)
OLTP / OLAPMany small fast transactions (a shop) / big analytical queries (a report warehouse)
MigrationA versioned script that changes the schema (tools: Flyway, Liquibase, Alembic)
ReplicaA read-only copy of the database, kept up to date from the main one
Connection poolA set of open connections an app reuses, because opening one is slow
DialectOne database's flavour of SQL

For role-specific examples, see SQL for SDET and the complete guide.