← All 45 books MySQL 8, one command per page Get the full edition · £10
A paper-engine book

MySQL 8, one command per page

The twenty-five commands a working developer actually types.


Steve Hodgkiss 8 commands

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

Contents


Part 1 · Reading data4
SELECT5
JOIN6
Part 2 · Writing data7
UPDATE8
TRUNCATE9
Part 3 · Shaping tables10
CREATE TABLE11
Indexes12
Part 4 · Administration
EXPLAIN13
Part 5 · Modern MySQL
WITH14
Part 1 of 5
Getting answers out
1

Reading data

The questions you ask a table a hundred times a week.


In this part
  1. 01SELECT
  2. 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.

MySQL 8 · No. 01
Reading

SELECT

Ask a table a question

users 10M rows Result 3 cols · 3 rows the three you asked for, only the rows that match

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.

TRY IT NOW

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.

MySQL 8 · No. 02
Reading

JOIN

Two tables, one answer

usersordersid = 1id = 2id = 3user_id = 1user_id = 2user_id = 9INNER JOIN keeps the pairs. The unmatched rows are gone.

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.

TRY IT NOW

SELECT u.email, o.total FROM users u JOIN orders o ON o.user_id = u.id;

Part 2 of 5
Rows in, rows out
2

Writing data

Putting rows where you want them, and removing the ones you don't.


In this part
  1. 01UPDATE
  2. 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.

MySQL 8 · No. 03
Writing

UPDATE

Change the rows the gate picks

usersid = 40id = 41id = 42id = 43WHERE id = 42the gateno WHERE: every row changesThe gate picks the rows. No gate means all of them.

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.

TRY IT NOW

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.

MySQL 8 · No. 04
Writing

TRUNCATE

Empty the whole table, all at once

DELETE · ROW BY ROW TRUNCATE · THE WHOLE MACHINE logs 4M rows row by row Bin triggers fire logs 4M rows drop + re-create Empty auto_inc reset Slow undo kept · ROLLBACK ok Fast implicit commit · final No rollback. No triggers. No row count.

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.

TRY IT NOW

TRUNCATE TABLE logs;

Part 3 of 5
The shape of your data
3

Shaping tables

Deciding what every row will look like, before there are any rows.


In this part
  1. 01CREATE TABLE
  2. 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.

MySQL 8 · No. 05
Shaping

CREATE TABLE

Decide the shape before there are rows

Column total Type DECIMAL(10,2) Key PRIMARY KEY (id) orders id (PK) user_id (INDEX) total DECIMAL(10,2) money in DECIMAL, never FLOAT

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.

TRY IT NOW

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.

MySQL 8 · No. 06
Shaping

Indexes

A sorted map, not a faster table

NO INDEX · WALK EVERY ROWreads 30 cells to find 5sorted on user_iduser_id = 7user_id = 7user_id = 8user_id = 9jump to the blockreads 3, skips 1The walk reads everything. The jump reads what it needs.

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.

TRY IT NOW

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.

MySQL 8 · No. 07
Admin

EXPLAIN

Read the plan before you guess at slow

Query WHERE user_id = 42 EXPLAIN plan only Plan type · key · rows type: ALL rows: 4000000 · key: NULL ANALYZE 8.0.18+ · runs it Truth actual rows + ms EXPLAIN guesses · EXPLAIN ANALYZE knows

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.

TRY IT NOW

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.

MySQL 8 · No. 08
Modern

WITH

Name the middle step, then query it

WITH big AS (SELECT ...) runs first · gets a name Result rows · readable SELECT ... FROM big ... the main query · uses it like a table Nested IN (SELECT ... Name it once · read it twice

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.

TRY IT NOW

WITH big AS (SELECT * FROM orders WHERE total > 100) SELECT COUNT(*) FROM big;

Index

Index


CREATE TABLE11
EXPLAIN13
Indexes12
JOIN6
SELECT5
TRUNCATE9
UPDATE8
WITH14