MENU
Retrieving Data SELECT and Joins
Section 3.2 read rows from one table. Real questions cross tables: which customers never ordered, what each order is worth, which category sits under which. This section answers them with the clauses of SELECT, the join types and the set operators, on a freshly loaded bookshop database. Sorting and paging are in Section 3.2.7.3.5.1 The Logical Order in Which a SELECT Executes
You write SELECT first, but logically the server evaluates it sixth: rows come from FROM and its joins, WHERE filters them, groups form and HAVING filters them, window functions run, and only then the select list, DISTINCT, ORDER BY and LIMIT. Each step sees only what earlier steps made.
Figure 3.4: The logical processing order of a SELECT statementSo a column alias, born in step 6, is invisible to WHERE in step
2:SELECT customer_id, COUNT(*) AS n FROM orders WHERE n > 1 GROUP BY customer_id;
SELECT customer_id, COUNT(*) AS n FROM orders WHERE status <> 'cancelled'
GROUP BY customer_id HAVING n > 1 ORDER BY n DESC, customer_id;
ERROR 1054 (42S22): Unknown column 'n' in 'where clause'
+-------------+---+
| customer_id | n |
+-------------+---+
| 1 | 2 |
| 2 | 2 |
| 3 | 2 |
+-------------+---+
3 rows in set (0.000 sec)
3.5.2 Projections, Aliases, and DISTINCT
The select list is the projection: the columns and expressions the result carries. AS names an output column (backticks allow spaces); a table alias such as products AS p shortens references and is compulsory when a table appears twice (Section 3.5.8).+-----------+--------------+
| title | member price |
+-----------+--------------+
| BK-SQL-01 | 40.050 |
| BK-SQL-02 | 26.100 |
+-----------+--------------+
2 rows in set (0.000 sec)
3.5.3 Filtering with WHERE
WHERE keeps each row for which its condition is true, combining comparisons, IN, BETWEEN, LIKE and IS NULL (Section 3.7) with AND, OR and NOT. Both queries below look right:SELECT sku FROM products WHERE category_id = 2 OR category_id = 3 AND price > 40;
SELECT id FROM reviews WHERE product_id IN (1, 6) AND NOT (body LIKE '%chapter%');
+-----------+
| sku |
+-----------+
| BK-PHP-01 |
| BK-LAR-01 |
| BK-LNX-01 |
| BK-SQL-01 |
+-----------+
4 rows in set (0.000 sec)
+----+
| id |
+----+
| 1 |
+----+
1 row in set (0.001 sec)
3.5.4 GROUP BY, HAVING, and ROLLUP
GROUP BY collapses rows sharing its expressions into one row per group for the aggregates (Section 3.8), and HAVING filters the groups. Under the default ONLY_FULL_GROUP_BY mode every select-list column must be grouped, aggregated, or functionally dependent on the grouped columns:SELECT customer_id, status, COUNT(*) FROM orders GROUP BY customer_id;
SELECT IF(GROUPING(category_id), 'all', category_id) AS category,
COUNT(*) AS titles, SUM(stock) AS units
FROM products WHERE category_id IN (2, 3)
GROUP BY ROLLUP (category_id);
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains
nonaggregated column 'shop.orders.status' which is not functionally dependent on columns in
GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
+----------+--------+-------+
| category | titles | units |
+----------+--------+-------+
| 2 | 3 | 52 |
| 3 | 2 | 12 |
| all | 5 | 64 |
+----------+--------+-------+
3 rows in set (0.000 sec)
3.5.5 Cross Joins and the Cartesian Product
Logically, every join starts from the Cartesian product: each left row paired with each right row. A cross join returns it unfiltered, so 8 customers and 8 products make 64 rows. That suits grids of every combination: recommendations, report skeletons, test data.SELECT cu.name, p.sku FROM customers AS cu CROSS JOIN products AS p
WHERE cu.country = 'MY' AND p.category_id = 3;
SELECT c.name, o.id
FROM customers AS c, orders AS o JOIN order_items AS i
ON i.order_id = o.id AND o.customer_id = c.id;
+--------------+-----------+
| name | sku |
+--------------+-----------+
| Chen Wei | BK-SQL-01 |
| Chen Wei | BK-SQL-02 |
| Farah Rahman | BK-SQL-01 |
| Farah Rahman | BK-SQL-02 |
+--------------+-----------+
4 rows in set (0.000 sec)
ERROR 1054 (42S22): Unknown column 'c.id' in 'on clause'
3.5.6 INNER JOIN and the ON Clause
An inner join keeps only the pairs that satisfy ON, which usually follows a foreign key. This query prices each paid order and names its customer:SELECT o.id, c.name, SUM(i.quantity * i.unit_price) AS total
FROM orders AS o
INNER JOIN customers AS c ON c.id = o.customer_id
INNER JOIN order_items AS i ON i.order_id = o.id
WHERE o.status = 'paid'
GROUP BY o.id
ORDER BY total DESC;
+----+--------------+-------+
| id | name | total |
+----+--------------+-------+
| 9 | Ben Carter | 53.99 |
| 7 | Farah Rahman | 50.00 |
| 3 | Chen Wei | 49.00 |
+----+--------------+-------+
3 rows in set (0.002 sec)
3.5.7 LEFT and RIGHT OUTER JOIN
A left outer join keeps every row of the left table; where no right row matches, the right-side columns are NULL. RIGHT JOIN is the mirror image, and MySQL rewrites it internally as a left join, so prefer LEFT JOIN and put the table you must keep first. OUTER is optional.
Figure 3.5: Which rows each join keeps, for A(a) = 1-4 and B(b) =
3-6The classic outer-join bug is a filter in the wrong clause. Both
queries look for the Malaysian customers' shipped orders:SELECT c.name, o.id FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id AND o.status = 'shipped'
WHERE c.country = 'MY';
SELECT c.name, o.id FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE c.country = 'MY' AND o.status = 'shipped';
+--------------+------+
| name | id |
+--------------+------+
| Chen Wei | NULL |
| Farah Rahman | NULL |
+--------------+------+
2 rows in set (0.001 sec)
Empty set (0.000 sec)
3.5.8 Self-Joins and Joining More Than Two Tables
A self-join joins a table to itself under two aliases. categories.parent_id points at another categories row, so a product's department takes two joins, one hop per foreign key; the second is a LEFT JOIN because top-level categories have no parent:SELECT p.sku, cat.name AS category, top.name AS department
FROM products AS p
JOIN categories AS cat ON cat.id = p.category_id
LEFT JOIN categories AS top ON top.id = cat.parent_id
WHERE p.id IN (2, 8);
SELECT name FROM categories WHERE id NOT IN (SELECT parent_id FROM categories);
+-----------+-------------+------------+
| sku | category | department |
+-----------+-------------+------------+
| BK-SQL-01 | Databases | Books |
| AC-STK-01 | Accessories | NULL |
+-----------+-------------+------------+
2 rows in set (0.000 sec)
Empty set (0.001 sec)
3.5.9 Emulating a FULL OUTER JOIN
A full outer join keeps unmatched rows from both sides, which is what reconciling two lists needs. MySQL 9.7 has none. Here a supplier's cost feed lacks one Databases title and lists one the shop does not stock:CREATE TABLE supplier_feed (sku VARCHAR(20) PRIMARY KEY, cost DECIMAL(8,2) NOT NULL);
INSERT INTO supplier_feed VALUES ('BK-SQL-01', 26.00), ('BK-PG-01', 21.50);
SELECT * FROM products FULL JOIN supplier_feed f ON products.sku = f.sku;
SELECT p.sku AS ours, f.sku AS theirs, p.price, f.cost
FROM products AS p LEFT JOIN supplier_feed AS f ON f.sku = p.sku
WHERE p.category_id = 3
UNION ALL
SELECT p.sku, f.sku, p.price, f.cost
FROM products AS p RIGHT JOIN supplier_feed AS f ON f.sku = p.sku
WHERE p.id IS NULL;
Query OK, 0 rows affected (0.040 sec)
Query OK, 2 rows affected (0.012 sec)
Records: 2 Duplicates: 0 Warnings: 0
ERROR 1054 (42S22): Unknown column 'products.sku' in 'on clause'
Warning (Code 4119): Using FULL as unquoted identifier is deprecated, please use quotes or
rename the identifier.
Error (Code 1054): Unknown column 'products.sku' in 'on clause'
+-----------+-----------+-------+-------+
| ours | theirs | price | cost |
+-----------+-----------+-------+-------+
| BK-SQL-01 | BK-SQL-01 | 44.50 | 26.00 |
| BK-SQL-02 | NULL | 29.00 | NULL |
| NULL | BK-PG-01 | NULL | 21.50 |
+-----------+-----------+-------+-------+
3 rows in set (0.001 sec)
3.5.10 UNION, INTERSECT, and EXCEPT
Set operators combine whole results: UNION returns rows from either query, INTERSECT rows in both, EXCEPT rows of the first absent from the second. INTERSECT and EXCEPT arrived in MySQL 8.0.31. Operands may be SELECT, TABLE or VALUES statements with equal column counts.SELECT product_id FROM order_items EXCEPT SELECT product_id FROM reviews;
SELECT 'order' AS kind, id, ordered_at AS happened FROM orders WHERE customer_id = 1
UNION ALL
SELECT 'review', id, created_at FROM reviews WHERE customer_id = 1
ORDER BY happened;
+------------+
| product_id |
+------------+
| 5 |
| 8 |
+------------+
2 rows in set (0.000 sec)
+--------+----+---------------------+
| kind | id | happened |
+--------+----+---------------------+
| order | 1 | 2026-03-02 10:15:00 |
| review | 1 | 2026-03-20 18:00:00 |
| order | 4 | 2026-05-05 12:00:00 |
| review | 5 | 2026-05-20 07:00:00 |
+--------+----+---------------------+
4 rows in set (0.001 sec)
3.5.11 Table Value Constructors with VALUES ROW()
Since MySQL 8.0.19, VALUES is a statement of its own, a table value constructor whose rows are written ROW(...). Alone, its columns are named column_0, column_1 and so on; as a derived table, you name them in a column list, giving you a lookup table without creating one:SELECT s.status, s.label, COUNT(o.id) AS orders
FROM (VALUES ROW('paid', 'Paid'), ROW('shipped', 'On its way'),
ROW('refunded', 'Refunded')) AS s (status, label)
LEFT JOIN orders AS o ON o.status = s.status
GROUP BY s.status, s.label;
VALUES ROW(1, 2), ROW(3);
+----------+------------+--------+
| status | label | orders |
+----------+------------+--------+
| paid | Paid | 3 |
| shipped | On its way | 4 |
| refunded | Refunded | 0 |
+----------+------------+--------+
3 rows in set (0.001 sec)
ERROR 1136 (21S01): Column count doesn't match value count at row 2
3.5.12 CASE, IF(), and Pivoting Rows into Columns
CASE is SQL's conditional expression. The searched form, CASE WHEN price >= 40 THEN 'premium' WHEN price >= 20 THEN 'standard' ELSE 'budget' END, returns the first true branch, so order branches from the most restrictive. The simple form, CASE category_id WHEN 5 THEN 'merch' END, compares one value with a list. A miss without ELSE gives NULL, and IF(cond, a, b) is MySQL's two-way shorthand. A condition inside an aggregate pivots a column's values into columns:SELECT c.name,
COUNT(CASE WHEN o.status = 'shipped' THEN 1 END) AS shipped,
SUM(o.status = 'paid') AS paid,
IF(COUNT(*) > 1, 'repeat', 'new') AS buyer
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.id BETWEEN 3 AND 5
GROUP BY c.id;
+-------------+---------+------+--------+
| name | shipped | paid | buyer |
+-------------+---------+------+--------+
| Chen Wei | 0 | 1 | repeat |
| Dara Okafor | 0 | 0 | new |
| Elif Yilmaz | 1 | 0 | new |
+-------------+---------+------+--------+
3 rows in set (0.001 sec)