MySQL 8, one command per page
The twenty-five commands a working developer actually types.
One command, one mechanism, one page.
MySQL 8, one command per page
The twenty-five commands a working developer actually types.
Set in Space Grotesk, Inter and JetBrains Mono (SIL Open Font License).
Command behaviour verified against the MySQL 8.0 Reference Manual: dev.mysql.com/doc/refman/8.0
Your purchase is for personal use only. You do not have redistribution rights: please do not share, resell, or republish this book or its pages.
© 2026 Steve Hodgkiss. All rights reserved. Personal use only; no redistribution rights.
Edition 1.0 · stevehodgkiss.net
Contents
Reading data
The questions you ask a table a hundred times a week.
- 01SELECT
- 02JOIN
SELECT picks the rows you want out of a table and only the columns you name. SELECT star drags every column over the wire, including the heavy ones nobody asked for.
SELECT
Let's say a page needs a customer's name and id. Somebody reaches for `SELECT *` because it's shorter, and every column crosses the network on every call, including a `bio` nobody will render.
A SELECT has three slots: the columns, the table, the condition. Name the columns, only the ones the page uses, and the engine ships exactly those. Months later somebody adds a heavy column, and your query doesn't even notice.
Name what you need. Nothing else comes along.
SELECT id, email FROM users WHERE active = 1;
JOIN pairs rows from two tables on a shared key. INNER JOIN keeps only the rows that match on both sides. Without an ON clause, MySQL pairs every row with every row.
JOIN
Let's say an order needs the buyer's email, and the email lives in users while the money lives in orders. One table holds half the answer. The join pairs rows that share a key: orders.user_id points at users.id.
The ON clause is the pairing rule, and it matters more than the keyword. INNER JOIN keeps only the pairs that match. And if you forget the ON entirely, MySQL pairs every row with every row, and a thousand orders become a million.
One shared key, one answer.
SELECT u.email, o.total FROM users u JOIN orders o ON o.user_id = u.id;
Writing data
Putting rows where you want them, and removing the ones you don't.
- 01UPDATE
- 02TRUNCATE
UPDATE changes values in the rows the WHERE clause picks. Without WHERE, it changes every row in the table. Writing the condition as a SELECT first is the cheap safety check.
UPDATE
Let's say support asks you to deactivate one account. You type UPDATE users SET active = 0, your hand slips, Enter. Every user is now deactivated, and outside a transaction there's no undo.
The WHERE clause is the whole statement. Write it as a SELECT first and look at the rows it returns, then swap the verb. Inside a transaction you can ROLLBACK and breathe.
Say the WHERE out loud before you press Enter.
UPDATE users SET active = 0 WHERE id = 42;
TRUNCATE empties a table by dropping it and re-creating it. Fast, resets AUTO_INCREMENT, fires no triggers, commits itself, and cannot be rolled back. DELETE removes rows one by one inside a transaction.
TRUNCATE
Let's say the logs table holds four million rows and none of them matter anymore. `DELETE FROM logs;` walks all four million, one at a time, firing triggers and writing undo for every row. TRUNCATE does not delete rows at all: it drops the table and re-creates it empty. Seconds instead of hours.
The trade is finality. It commits itself, fires no ON DELETE triggers, and cannot be rolled back. It reports "0 rows affected" because it has no count to give you, and it resets AUTO_INCREMENT back to its start.
Same empty table, from a different machine.
TRUNCATE TABLE logs;
Shaping tables
Deciding what every row will look like, before there are any rows.
- 01CREATE TABLE
- 02Indexes
CREATE TABLE fixes the shape of every future row: each column gets a type, and the keys are chosen up front. DECIMAL stores money exactly; FLOAT does not.
CREATE TABLE
Let's say you're creating the orders table and you're in a hurry. Six months in, finance asks why the totals are off by a cent here and there. FLOAT is a binary fraction and it cannot hold 0.10 exactly, so rounding drift compounds.
This one statement decides all of it: the type per column, DECIMAL(10,2) for money, NOT NULL where a value must exist, and the keys. Every row that ever lands follows this shape.
The shape is cheap now and expensive later.
CREATE TABLE orders (id INT AUTO_INCREMENT PRIMARY KEY, total DECIMAL(10,2) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
An index is a sorted lookup structure keyed on a column. The engine can jump straight to matching rows instead of walking the table. It speeds reads and slows writes, and costs space.
Indexes
Let's say a query filters orders by user_id and there's no index there. Every query walks the whole table to find its handful of rows, and the table only grows.
An index is a sorted structure keyed on that column: the engine jumps straight to matching rows. The trade is real, though: reads get fast, every INSERT and UPDATE has one more structure to maintain, and the index takes disk. Index the columns your WHERE and JOIN actually use.
Fast reads, taxed writes. Both are true.
CREATE INDEX idx_orders_user ON orders (user_id);
EXPLAIN shows the plan the MySQL optimizer chose for a query without running it. EXPLAIN ANALYZE, added in 8.0.18, runs the query and reports actual rows and timings against the estimates.
EXPLAIN
Let's say one page takes three seconds and you're fairly sure it's one query. You could add an index and hope, or you could put EXPLAIN in front of the query and read what the optimizer chose.
Three columns carry most of it. `type` is how the engine reaches the rows, and `ALL` means a full table scan, every row read. `key` is the index it picked, NULL means none. `rows` is its estimate of the work. On 8.0.18 or newer, EXPLAIN ANALYZE runs the query and shows actual rows and milliseconds beside the guesses.
Don't guess at slow. Read the plan.
EXPLAIN SELECT * FROM orders WHERE user_id = 42;
WITH names a subquery and lets you use it like a table in the main query. Common table expressions, new in MySQL 8, replace nested subqueries with something readable.
WITH
Let's say the report needs the big orders, then the customers behind them, then a count over that. In old MySQL you nest subqueries three levels deep, parenthesized to the right edge of the screen, and nobody can change it safely.
WITH, new in MySQL 8, flips the order: name the middle step first, then query it by name like an ordinary table. The reader meets the definition before the question that uses it, the same way you'd explain it out loud. Same engine work, a query the next person can actually read.
Name it once, use it like a table.
WITH big AS (SELECT * FROM orders WHERE total > 100) SELECT COUNT(*) FROM big;