Reading data with SELECT: choosing columns, filtering rows, the rules of NULL, sorting, paging, removing duplicates, computing values, CASE, the everyday functions, and the order in which a query is really evaluated.
10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.
SELECT is the only statement that reads data, and every query has the same skeleton: FROM names the table to read, SELECT lists the columns (or expressions) to return. The result is itself a table, with rows and named columns, which is why you can feed one query's result into another.
Think of FROM as pulling a sheet out of the filing cabinet and SELECT as a stencil you lay over it: only the columns cut out of the stencil show through. The sheet itself is untouched; a query never changes the data it reads.
FROM products is logically evaluated first: it produces all 8 rows of the table with all 5 columns (id, name, category_id, price, stock).
SELECT name, price then keeps just those two columns, in that order. The column headers in the result are the names you selected.
There's no WHERE, so every row comes back, and no ORDER BY, so the order is whatever the engine produced. Here it happens to be primary-key order because InnoDB read the table through its clustered index, but nothing guarantees it (see Sorting with ORDER BY).
8 rows in set (0.000 sec) is the client's summary: the row count and how long the server took.
AdvancedSELECT * and why production code avoids it
SELECT * means "every column, in table order". It's perfect at the prompt when you're exploring:
The * expands to id, name, parent_id at the moment the statement is parsed.
That's exactly the problem in application code: add a column (say a 2 MB description TEXT) and every SELECT * starts shipping it over the network, whether or not anyone reads it.
Code that reads columns by position breaks if the column order changes, and joins with SELECT * return duplicate names (id from both tables) that drivers silently collapse into one key.
Selecting only the columns you need also lets MySQL answer from an index alone (a covering index, see Covering indexes). Name your columns in real code.
AdvancedSELECT without a table, and qualified names
FROM is optional when you're only computing values. MySQL also accepts the Oracle-style dummy table DUAL:
SQL
SELECT1+1AS two,NOW()ISNOTNULLAS has_clock,'hi'AS greeting;SELECT1+1FROM DUAL;SELECT ch01.products.name FROM ch01.products WHERE ch01.products.id =2;
Result
+-----+-----------+----------+
| two | has_clock | greeting |
+-----+-----------+----------+
| 2 | 1 | hi |
+-----+-----------+----------+
+-------+
| 1 + 1 |
+-------+
| 2 |
+-------+
+-------------+
| name |
+-------------+
| MacBook Air |
+-------------+
What’s happening
With no FROM, the query produces exactly one row of computed values. MySQL has no boolean type, so NOW() IS NOT NULL comes back as 1 (true). 0 is false.
Without an alias the column is named after the expression text, 1 + 1. DUAL exists only so code written for Oracle, where FROM is mandatory, still runs.
Names can be fully qualified as database.table.column. You rarely need all three parts, but database.table lets one query read tables from two databases on the same server, and table.column is how you say which id you mean when two joined tables both have one.
WHERE keeps only the rows for which a condition is true. It's evaluated for each row the FROM clause produces, before anything is grouped, sorted or selected. A condition is built from comparisons (=, <> or !=, <, <=, >, >=), range and set tests (BETWEEN, IN), pattern matches (LIKE, REGEXP), IS NULL, and the logic operators AND, OR and NOT.
The mental model is a bouncer with a checklist: each row walks up, the bouncer evaluates the checklist against that one row, and only rows that get a clear "yes" are let in. "No" and "don't know" (which is what NULL produces, next topic) are both turned away.
SQL
SELECT id, name, price, stock
FROM products
WHERE price <1000AND stock >10;
AND binds tighter than OR, just as * binds tighter than +. So the condition is read as category_id = 2 OR (category_id = 3 AND price < 1000).
Every laptop passes the first half on its own, so the MacBook Air (category 2, 1299) is in, whatever its price.
Phones must also be under 1000: the Pixel (799) is in, the iPhone (1099) is out.
The fix is parentheses: WHERE (category_id = 2 OR category_id = 3) AND price < 1000, which returns only the ThinkBook and the Pixel. Or use category_id IN (2, 3) AND price < 1000, which can't be misread.
AdvancedBETWEEN, IN and the date-range trap
SQL
SELECT id, name, price FROM products WHERE price BETWEEN39AND249;SELECT id, name, city FROM customers WHERE city IN('Toronto','London');SELECT id,status, ordered_at FROM orders
WHERE ordered_at BETWEEN'2025-02-01'AND'2025-02-14';SELECT id,status, ordered_at FROM orders
WHERE ordered_at >='2025-02-01'AND ordered_at <'2025-02-15';
BETWEEN 39 AND 249 is inclusive at both ends: it means price >= 39 AND price <= 249, so the 39.00 book and the 249.00 headphones are both in.
IN ('Toronto', 'London') is shorthand for city = 'Toronto' OR city = 'London', and easier to read and extend.
The third query wants "the first two weeks of February" but misses order 4, placed on 14 February at 18:00. ordered_at is a DATETIME, so the bare date '2025-02-14' is converted to 2025-02-14 00:00:00, and anything later that day is outside the range.
The fourth query uses a half-open range, >= start AND < day after end, which includes the whole last day whatever the time, and works the same for DATE, DATETIME and TIMESTAMP columns. Prefer it for every date range.
AdvancedLIKE, REGEXP and collations
LIKE matches patterns: % is any run of characters (including none), _ is exactly one character. Whether 'a' equals 'A' is decided by the column's collation, and MySQL's default, utf8mb4_0900_ai_ci, is accent-insensitive and case-insensitive:
SQL
SELECT id, name FROM products WHERE name LIKE'%book%';SELECT id, name FROM products WHERE name LIKE'Pixel __';SELECT id, name FROM products WHERE name REGEXP'^[A-Z]{3} ';SELECT id, name FROM customers WHERE name ='ROHIT';SELECT id, name FROM customers WHERE name COLLATE utf8mb4_0900_as_cs ='ROHIT';SELECT'Zoë'='zoe'AS accent_insensitive,'abc'='ABC'AS case_insensitive;
Result
+----+--------------+
| id | name |
+----+--------------+
| 1 | ThinkBook 14 |
| 2 | MacBook Air |
+----+--------------+
+----+----------+
| id | name |
+----+----------+
| 3 | Pixel 10 |
+----+----------+
+----+---------------------------+
| id | name |
+----+---------------------------+
| 5 | SQL Performance Explained |
+----+---------------------------+
+----+-------+
| id | name |
+----+-------+
| 1 | Rohit |
+----+-------+
Empty set (0.000 sec)
+--------------------+------------------+
| accent_insensitive | case_insensitive |
+--------------------+------------------+
| 1 | 1 |
+--------------------+------------------+
What’s happening
'%book%' finds "Book" anywhere in the name, in ThinkBook and MacBook, even though the pattern is lowercase: the comparison uses the column's case-insensitive collation.
'Pixel __' needs exactly two characters after the space: "Pixel 10" matches; "Pixel 9" or "Pixel 10 Pro" wouldn't.
REGEXP (also spelled RLIKE or REGEXP_LIKE()) uses ICU regular expressions since 8.0. ^[A-Z]{3} means "starts with three letters and a space"; only "SQL Performance Explained" fits. (The ThinkBook starts with five letters.)
name = 'ROHIT' finds Rohit because the collation ignores case. Forcing a case- and accent-sensitive collation (utf8mb4_0900_as_cs) on the column makes the same comparison return nothing.
'Zoë' = 'zoe' is 1 too: accents are ignored as well. Great for search boxes, a surprise for usernames. The older trick name = BINARY 'ROHIT' still works on 9.7 but warns 'BINARY expr' is deprecated and will be removed in a future release. Please use CAST instead.
AdvancedPitfall: loose comparisons and trailing spaces
NULL is not a value; it's a marker for "unknown" or "missing". Sofia's city is NULL because we don't know it, not because she lives in a city called nothing. Every comparison with an unknown is itself unknown: is Sofia's city equal to Toronto? We can't say. So SQL logic has three truth values: TRUE, FALSE and UNKNOWN (which MySQL displays as NULL).
A good mental model is a sealed envelope. NULL is an envelope whose contents you haven't seen. "Is the envelope equal to 5?" Unknown. "Are these two sealed envelopes equal?" Also unknown, even if they look the same. The only question you can answer is "is it a sealed envelope?", which is what IS NULL asks. And WHERE only keeps rows whose condition is definitely TRUE.
SQL
SELECT id, name, city FROM customers WHERE city =NULL;SELECT id, name, city FROM customers WHERE city ISNULL;SELECT id, name, city FROM customers WHERE city <>'Toronto';
Result
Empty set (0.000 sec)
+----+-------+------+
| id | name | city |
+----+-------+------+
| 4 | Sofia | NULL |
+----+-------+------+
+----+------+--------+
| id | name | city |
+----+------+--------+
| 2 | Maya | Mumbai |
| 3 | Liam | London |
| 6 | Zara | Dubai |
+----+------+--------+
What’s happening
city = NULL asks "is the city equal to unknown?" For every row the answer is UNKNOWN, even for Sofia, whose city isNULL. WHERE drops anything that isn't TRUE, so the result is empty. MySQL gives no error or warning; the query is valid, just useless.
city IS NULL is the special test for the marker itself. It's always TRUE or FALSE, never unknown, and finds Sofia.
city <> 'Toronto' returns Maya, Liam and Zara but not Sofia. For her the test is NULL <> 'Toronto', which is UNKNOWN, so she is dropped from "not Toronto" as well as from "Toronto". She's in neither half.
To include her, say so: WHERE city <> 'Toronto' OR city IS NULL returns four rows. Forgetting this is one of the most common bugs in reporting queries.
NULL = NULL and NULL <> NULL are both NULL (unknown): two sealed envelopes can't be compared. IS NULL gives a definite 1.
<=> is MySQL's null-safe equality: it treats two NULLs as equal (1) and NULL vs a value as not equal (0), never unknown. The standard spelling is IS NOT DISTINCT FROM, which PostgreSQL supports; MySQL 9.7 answers it with a 1064 syntax error.
AND and OR only stay unknown when the known part doesn't settle it. NULL AND FALSE is 0, because one false makes AND false whatever the unknown is. NULL OR TRUE is 1 for the same reason. But NULL AND TRUE and NULL OR FALSE depend on the unknown, so they're NULL.
NOT NULL (the expression, not the constraint) is still unknown: the opposite of "don't know" is "don't know".
Arithmetic, string functions and comparisons all propagateNULL: 5 + NULL, CONCAT('a', NULL) and NULL > 0 are all NULL. One missing input makes the whole expression missing.
AdvancedReplacing NULLs: COALESCE, IFNULL and NULLIF
SQL
SELECT name, city,COALESCE(city,'unknown')AS city_label,
IFNULL(city,'-')AS ifnull_city,NULLIF(city,'Toronto')AS nullif_toronto
FROM customers;
Result
+-------+---------+------------+-------------+----------------+
| name | city | city_label | ifnull_city | nullif_toronto |
+-------+---------+------------+-------------+----------------+
| Rohit | Toronto | Toronto | Toronto | NULL |
| Maya | Mumbai | Mumbai | Mumbai | Mumbai |
| Liam | London | London | London | London |
| Sofia | NULL | unknown | - | NULL |
| Noah | Toronto | Toronto | Toronto | NULL |
| Zara | Dubai | Dubai | Dubai | Dubai |
+-------+---------+------------+-------------+----------------+
What’s happening
COALESCE(a, b, c, …) returns the first argument that isn't NULL. For Sofia city is NULL, so it falls through to 'unknown'; for everyone else it's just their city. It's standard SQL and takes any number of arguments.
IFNULL(a, b) is MySQL's two-argument version: the same result here, with '-' as the fallback. Prefer COALESCE for portability.
NULLIF(a, b) goes the other way: it returns NULLwhen the two are equal, otherwise a. Both Toronto customers become NULL. Its classic use is avoiding division by zero: total / NULLIF(count, 0) gives NULL instead of dividing by zero.
Sofia's nullif_toronto is NULL because her city was already NULL; NULLIF(NULL, 'Toronto') returns its first argument.
?NOT IN and a NULL
"Which employees manage nobody?" What does this return?
SQL
SELECT name FROM employees
WHERE id NOTIN(SELECT manager_id FROM employees);
Show answer
Nothing: Empty set. The six people who manage nobody (Chloe, Dev, Farid, Grace, Isla, Jonas) are all missing.
What’s happening
The subquery returns every manager_id, and Asha's is NULL because she has no manager. So the list is (NULL, 1, 1, 2, 1, 5, 5, 1, 8, 1).
id NOT IN (list) means id <> NULL AND id <> 1 AND id <> 2 AND …. For Chloe (id 3) every comparison with a real number is true, but 3 <> NULL is UNKNOWN.
TRUE AND … AND UNKNOWN is UNKNOWN, so WHERE drops Chloe. The same happens to every row: one NULL in a NOT IN list makes the whole test unknown for every row.
Fix it by filtering the NULL out (WHERE manager_id IS NOT NULL inside the subquery) or, better, use NOT EXISTS (SELECT 1 FROM employees r WHERE r.manager_id = e.id), which isn't affected by NULLs. Both return the six names; I ran both. NOT IN (1, 2, NULL) with a literal list has the same problem.
AdvancedNULLs in aggregates, sorting, DISTINCT and IN
NULL has a few more rules worth knowing, each covered where it applies:
COUNT(*) counts rows, COUNT(city) counts non-NULL values: on customers they give 6 and 5, and COUNT(DISTINCT city) gives 4 (Toronto, Mumbai, London, Dubai). SUM, AVG, MIN and MAX also skip NULLs (see COUNT(*) vs COUNT(column) vs COUNT(DISTINCT)).
DISTINCT and GROUP BY treat all NULLs as one group, even though NULL = NULL is unknown.
ORDER BY puts NULLs first in ascending order in MySQL (see the next topics).
A UNIQUE index allows many NULLs, because no NULL is equal to another.
IN (3, NULL) still finds the 3s (3 = 3 is true, so the OR is true), but never finds the NULLs.
A table has no order, so a result has no order either, unless you ask for one with ORDER BY. It sorts by one or more expressions, each ASC (the default) or DESC, and it's the only thing that guarantees the order rows come back in. Everything else ("it's always been in id order") is an accident of the current plan.
Think of sorting a deck of cards: first by suit, and within each suit by rank. With several sort keys, the second key only matters among rows that tie on the first, the third only among rows that tie on the first two, and so on.
SQL
SELECT name, department_id, salary
FROM employees
ORDERBY department_id, salary DESC;
The first key, department_id, is ascending (ASC is the default). Jonas's NULL department comes first: in MySQL, NULLs sort before every value in ascending order (and last in descending).
Within department 1, the second key salary DESC puts Asha (185000) first and Dev (118000) last.
Ben and Chloe tie on both keys (department 1, 142000). Their relative order is not defined: Ben came first here, but nothing promises that. If it matters, add a third key such as name or id.
DESC applies only to the key it follows. ORDER BY department_id, salary DESC is not "everything descending".
AdvancedPutting NULLs first or last
MySQL has no NULLS FIRST / NULLS LAST clause (PostgreSQL and Oracle do). Sort on a NULL test first instead:
SQL
SELECT name, city FROM customers ORDERBY city;SELECT name, city FROM customers ORDERBY city ISNULL, city;
Result
+-------+---------+
| name | city |
+-------+---------+
| Sofia | NULL |
| Zara | Dubai |
| Liam | London |
| Maya | Mumbai |
| Rohit | Toronto |
| Noah | Toronto |
+-------+---------+
+-------+---------+
| name | city |
+-------+---------+
| Zara | Dubai |
| Liam | London |
| Maya | Mumbai |
| Rohit | Toronto |
| Noah | Toronto |
| Sofia | NULL |
+-------+---------+
What’s happening
Plain ORDER BY city puts Sofia's NULL first, then the cities alphabetically.
city IS NULL evaluates to 0 for known cities and 1 for Sofia. Sorting by it first puts every 0 row before every 1 row.
The second key city then sorts the known cities alphabetically. Sofia ends up last.
Ordering by an expression like this can't use an index on city for the sort, which is fine for small results.
AdvancedORDER BY an alias, a position or an expression
SQL
SELECT name, price * stock AS stock_value FROM products ORDERBY stock_value DESC;SELECT name, price FROM products ORDERBY2DESCLIMIT3;
ORDER BY runs afterSELECT, so it can use the alias stock_value (unlike WHERE, see Aliases and computed columns). The Pixel's 20 × 799 = 15980 is the most money sitting on the shelf.
ORDER BY 2 means "the second column in the select list", here price. It's handy at the prompt, but in code it silently changes meaning when someone reorders the columns; prefer names.
You can also order by a column you didn't select, such as ORDER BY created_at while selecting only name, as long as the query doesn't use DISTINCT.
?Why is "pending" first?
SQL
SELECT id,statusFROM orders ORDERBYstatus;
The statuses are pending, paid, shipped and cancelled. Alphabetically cancelled would come first. What comes first here?
status is an ENUM('pending','paid','shipped','cancelled'). Internally MySQL stores each value as its position in the list: pending = 1, paid = 2, shipped = 3, cancelled = 4.
ORDER BY status sorts by those numbers, not by the text. That's why the order matches the ENUM definition.
To sort alphabetically, sort the text: ORDER BY CAST(status AS CHAR) returns cancelled, paid, paid, pending, shipped… (I ran it). To sort in a business order of your own choosing, use a CASE expression (see CASE expressions).
LIMIT n returns at most n rows; LIMIT n OFFSET m skips m rows first. They're applied last, after sorting, which is why LIMIT almost always comes with ORDER BY: "the top 3" only means something once you've said top by what.
Picture the sorted result as a long receipt. OFFSET is how far down you slide your finger; LIMIT is how many lines you then read out. The database still has to produce (and usually sort) everything above your finger, which is why big offsets get slow.
SQL
SELECT name, price
FROM products
ORDERBY price DESCLIMIT3;
Result
+--------------+---------+
| name | price |
+--------------+---------+
| MacBook Air | 1299.00 |
| iPhone 17 | 1099.00 |
| ThinkBook 14 | 899.00 |
+--------------+---------+
3 rows in set (0.000 sec)
What’s happening
ORDER BY price DESC sorts all 8 products from most to least expensive.
LIMIT 3 keeps the first three rows of that sorted result: MacBook Air, iPhone 17, ThinkBook 14.
MySQL can be smart about this: with a LIMIT, it doesn't fully sort all rows, it keeps only the best 3 seen so far (a "top-N" priority queue), and with an index on price it could read just three index entries and stop.
Without the ORDER BY, LIMIT 3 would return "some three rows", whichever the plan produced first.
AdvancedPages with OFFSET, and the LIMIT m, n syntax
SQL
SELECT name, price FROM products ORDERBY price DESCLIMIT3OFFSET3;SELECT name, price FROM products ORDERBY price DESCLIMIT3,3;SELECT name, price FROM products ORDERBY price DESCLIMIT100OFFSET6;
Page 2 with 3 per page skips 3 rows and takes the next 3: Pixel, Headphones, the 49.00 book. In general, page p (from 1) is LIMIT size OFFSET (p - 1) * size.
LIMIT 3, 3 is MySQL's older form, offset first, then count. It's the same query and a common source of off-by-a-page bugs, because the order is the reverse of LIMIT … OFFSET ….
Asking for more rows than remain isn't an error: LIMIT 100 OFFSET 6 just returns the last two products.
Large offsets are slow because the server must produce and throw away every skipped row: OFFSET 100000 reads 100,000 rows to return 20. Keyset pagination (WHERE price < last_seen_price …) avoids that; see Pagination: OFFSET vs keyset.
AdvancedWhat MySQL's LIMIT doesn't support
SQL
SELECT name, price FROM products ORDERBY price DESCFETCHFIRST3ROWS ONLY;SELECT name FROM products
WHERE id IN(SELECT product_id FROM order_items ORDERBY quantity DESCLIMIT2);SELECT name FROM products
WHERE id IN(SELECT product_id FROM(SELECT product_id FROM order_items ORDERBY quantity DESCLIMIT2)AS top2);
Result
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'FETCH FIRST 3 ROWS ONLY' at line 1
ERROR 1235 (42000): This version of MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'
+---------------------------+
| name |
+---------------------------+
| USB-C Charger |
| SQL Performance Explained |
+---------------------------+
What’s happening
FETCH FIRST n ROWS ONLY is the SQL-standard spelling of LIMIT, used by PostgreSQL, Oracle and SQL Server. MySQL 9.7 still doesn't accept it.
MySQL refuses LIMIT inside a subquery used with IN, ALL, ANY or SOME, with the oddly worded error 1235 that has said "doesn't yet support" for many versions.
The workaround is to wrap the limited query in a derived table (AS top2). The inner query becomes a materialised result, and IN reads from that.
The two largest quantities are product 7 (4 chargers in order 8) and product 5 (3 books in order 7), so those two names come back.
?Second-highest salary
A classic. What do these return?
SQL
SELECT name, salary FROM employees ORDERBY salary DESCLIMIT1OFFSET2;SELECTDISTINCT salary FROM employees ORDERBY salary DESCLIMIT1OFFSET2;
Show answer
The first returns Chloe, 142000.00; the second returns 130000.00.
Sorted by salary, the rows are Asha 185000, Ben 142000, Chloe 142000, Elena 130000, … Skipping two rows lands on Chloe, so the first query says the "third-highest salary" is 142000, which is really the second-highest again. (Whether Ben or Chloe is second is itself undefined.)
DISTINCT removes duplicate salaries beforeORDER BY and LIMIT: the distinct list is 185000, 142000, 130000, … Skipping two gives 130000, the true third-highest salary.
Which one is right depends on the question: "the third-highest-paid person" or "the third-highest salary". Interviewers ask this to see whether you notice ties.
SELECT DISTINCT removes duplicate rows from the result: if two rows have the same values in every selected column, only one is kept. It's applied after the select list is computed and before ORDER BY and LIMIT.
Imagine tipping a bag of coloured beads onto the table and keeping one of each colour. The important detail is what "colour" means: DISTINCT compares the whole selected row, not just the first column. Select two columns and it keeps one of each combination.
SQL
SELECTDISTINCT city FROM customers;
Result
+---------+
| city |
+---------+
| Toronto |
| Mumbai |
| London |
| NULL |
| Dubai |
+---------+
5 rows in set (0.000 sec)
What’s happening
There are six customers but only five rows: Rohit and Noah both have Toronto, so one Toronto row is dropped.
Sofia's NULL city is kept as a row of its own. For DISTINCT (and GROUP BY) all NULLs count as the same, even though NULL = NULL is unknown in a WHERE.
The order isn't sorted. EXPLAIN FORMAT=TREE shows the plan: Table scan on customers, then Temporary table with deduplication, then a scan of that temporary table, so rows come out in the order each city was first seen. Add ORDER BY city if you want them sorted.
Contrast COUNT(DISTINCT city), which is 4: aggregate functions skip NULLs, so the NULL "city" isn't counted.
AdvancedDISTINCT applies to the whole row
SQL
SELECTDISTINCT city, name FROM customers WHERE city ='Toronto';SELECTDISTINCT department_id, salary FROM employees WHERE department_id =1;
DISTINCT city, name doesn't mean "distinct cities, plus a name". Rohit and Noah differ in name, so both rows stay, and Toronto appears twice.
In department 1 there are four people but only three distinct (department_id, salary) pairs, because Ben and Chloe share (1, 142000.00).
DISTINCT is not a function, so SELECT DISTINCT(city), name is the same as SELECT DISTINCT city, name. The parentheses just wrap the first column.
AdvancedError 3065: ORDER BY a column not selected
SQL
SELECTDISTINCT city FROM customers ORDERBY created_at;
Result
ERROR 3065 (HY000): Expression #1 of ORDER BY clause is not in SELECT list, references column 'ch01.customers.created_at' which is not in SELECT list; this is incompatible with DISTINCT
What’s happening
After DISTINCT, Toronto is one row standing for two customers with two different created_at values. Which one should it sort by? There's no answer.
MySQL 5.7.5 and later refuse the query instead of guessing. (Older versions picked an arbitrary row.)
Either select the column too, or sort by an aggregate after grouping: SELECT city FROM customers GROUP BY city ORDER BY MIN(created_at) sorts each city by its first sign-up.
AdvancedDISTINCT vs GROUP BY, and DISTINCT as a smell
SELECT DISTINCT city FROM customers and SELECT city FROM customers GROUP BY city return the same five rows (I ran both), and MySQL often runs them with the same plan. Use DISTINCT when you only want unique rows and GROUP BY when you want to compute something per group (COUNT(*), SUM(…)).
A DISTINCT added to "fix" duplicate rows is often a smell: the duplicates usually come from a join that multiplies rows (one customer × many orders), and the honest fix is to change the join, for example to an EXISTS (see Anti-joins and semi-joins). DISTINCT also has a cost: it needs a temporary table or a sort over the whole result.
The select list isn't limited to stored columns: any expression can be a result column, such as quantity * unit_price or ROUND(price * 0.9, 2). An alias (AS name) gives that column a name. Tables get aliases too (FROM order_items AS oi), which keeps joins readable and is required when you join a table to itself.
Think of the result as a report you're designing: the stored columns are the raw figures, computed columns are the formulas in the extra columns, and aliases are the headings you write at the top. The underlying table never gets those columns; they exist only in this result.
SQL
SELECT oi.order_id,
p.name AS product,
oi.quantity * oi.unit_price AS line_total,ROUND(oi.unit_price *0.9,2)AS member_price
FROM order_items AS oi
JOIN products AS p ON p.id = oi.product_id
WHERE oi.order_id IN(1,7)ORDERBY line_total DESC;
order_items AS oi and products AS p are table aliases. After the FROM clause, the tables are called oi and p; oi.quantity says exactly which table's column you mean.
For each of the four order lines in orders 1 and 7, MySQL computes quantity * unit_price: 1 × 899.00 = 899.00, 3 × 39.00 = 117.00, 2 × 29.00 = 58.00. The result keeps the DECIMAL scale of 2.
ROUND(unit_price * 0.9, 2) computes a 10% discount, 899.00 × 0.9 = 809.100, rounded to 809.10. Without ROUND, multiplying by 0.9 adds a decimal place (809.100).
ORDER BY line_total DESC can use the alias because ORDER BY is evaluated after the select list. The two ThinkBook lines tie at 899.00, so their order is undefined.
AS is optional (p.name product works), but always write it: see the quiz below for what goes wrong without it.
AdvancedError 1054: aliases don't exist in WHERE
SQL
SELECT name, price * stock AS stock_value FROM products WHERE stock_value >5000;SELECT name, price * stock AS stock_value FROM products HAVING stock_value >5000;
WHERE is evaluated before the select list (see Logical query processing order), so when it runs, stock_value hasn't been computed and has no name. Error 1054 says so.
The portable fix is to repeat the expression: WHERE price * stock > 5000. Or compute it in a derived table or CTE and filter outside.
MySQL also lets HAVING refer to select-list aliases, and with no GROUP BY and no aggregates it simply filters the finished rows, so the second query works. It's a MySQL extension (PostgreSQL 18 rejects an alias in HAVING with ERROR: column "n" does not exist in my test) and it confuses readers; prefer repeating the expression in WHERE.
?A missing comma
SQL
SELECT name price FROM products WHERE id =1;
How many columns come back, and what are they called?
Show answer
One column, called price, containing the product name:
/ always does real division in MySQL: 7 / 2 is 3.5000 (four extra decimal places, set by div_precision_increment), not 3 as in PostgreSQL or SQL Server integer division. DIV is integer division; % or MOD() is the remainder.
Dividing by zero gives NULL with Warning 1365 Division by 0. In an INSERT or UPDATE under the default strict SQL mode it's an error instead: inserting 7 / 0 into a DECIMAL column failed with ERROR 1365 (22012): Division by 0.
Literals like 0.1 are exact DECIMALs, so 0.1 + 0.2 = 0.3 is true. Literals with an exponent (0.1e0) are DOUBLEs, and binary floating point can't represent 0.1 exactly: the sum is 0.30000000000000004, just like JavaScript. Store money in DECIMAL, never FLOAT or DOUBLE.
1 / 3 is rounded to four places (0.3333), and multiplying back gives 0.9999, not 1. When precision matters, multiply before you divide, and round once at the end.
CASE is SQL's if/else, and it's an expression: it produces a value, so it can go anywhere a value can, in the select list, WHERE, ORDER BY, GROUP BY, inside SUM(), in an UPDATE's SET. It comes in two forms. The searched form tests a list of conditions, CASE WHEN cond THEN result … ELSE default END. The simple form compares one expression against values, CASE expr WHEN value THEN result … END.
Think of it as a sorting machine with chutes: each row rolls past the WHEN tests in order and drops into the first chute whose test is true. If it passes every chute, it lands in ELSE, and if there's no ELSE, it lands on the floor as NULL.
For each product, MySQL evaluates the WHEN conditions top to bottom and stops at the first one that's true.
MacBook Air: 1299 >= 1000 is true → 'premium'. The second WHEN is never looked at.
ThinkBook: the first test fails (899 >= 1000 is false), the second passes (899 >= 200) → 'mid-range'. Because the first test already failed, the second doesn't need AND price < 1000: the order of the WHENs does that work.
The books and the charger fail both tests and fall to ELSE 'budget'.
END AS price_band closes the expression and names the column. The CASE is computed per row; nothing is stored.
AdvancedSimple CASE and the missing ELSE
SQL
SELECT id,status,CASEstatusWHEN'pending'THEN'Awaiting payment'WHEN'paid'THEN'Preparing'WHEN'shipped'THEN'On its way'ENDAS customer_message
FROM orders ORDERBY id;
Result
+----+-----------+------------------+
| id | status | customer_message |
+----+-----------+------------------+
| 1 | shipped | On its way |
| 2 | paid | Preparing |
| 3 | shipped | On its way |
| 4 | cancelled | NULL |
| 5 | shipped | On its way |
| 6 | pending | Awaiting payment |
| 7 | paid | Preparing |
| 8 | shipped | On its way |
+----+-----------+------------------+
What’s happening
The simple form CASE status WHEN 'pending' … compares status = 'pending', then status = 'paid', and so on. It's shorter when every test is an equality on the same expression.
Order 4 is cancelled, which no WHEN mentions, and there's no ELSE, so the result is NULL.
That's sometimes what you want, but more often a bug waiting for a new status. Add ELSE 'Unknown status' (or ELSE status) so new values show up visibly.
AdvancedPitfall: CASE WHEN NULL never matches
?First match wins
SQL
SELECT name, price,CASEWHEN price >100THEN'over 100'WHEN price >1000THEN'over 1000'ENDAS band
FROM products WHERE id IN(2,7);
What's the MacBook Air's band (price 1299), and the charger's (29)?
Show answer
MacBook Air is over 100, and the charger is NULL.
Result
+---------------+---------+----------+
| name | price | band |
+---------------+---------+----------+
| MacBook Air | 1299.00 | over 100 |
| USB-C Charger | 29.00 | NULL |
+---------------+---------+----------+
What’s happening
For the MacBook, the first test 1299 > 100 is already true, so CASE stops there. The > 1000 branch can never be reached by anything, because anything over 1000 is also over 100.
The charger fails both tests, and with no ELSE the result is NULL.
Put the most specific (narrowest) condition first: WHEN price > 1000 before WHEN price > 100.
AdvancedCASE in ORDER BY: a custom sort order
Suppose the warehouse wants paid orders first (they need packing), then pending, then shipped, with cancelled last, and the oldest first within each:
The CASE turns each status into a sort key: paid → 1, pending → 2, shipped → 3, anything else → 4.
ORDER BY sorts on that number, so both paid orders come first, then the one pending, the four shipped and finally the cancelled one.
Within a status, ordered_at sorts oldest first: order 2 (15 March) before 7 (2 April), and shipped orders 1, 3, 5, 8 in date order.
The sort key isn't in the select list and doesn't need to be. MySQL's FIELD(status, 'paid', 'pending', 'shipped') does the same job in one call, but returns 0 for unlisted values, which sorts them first.
AdvancedIF(), and CASE inside calculations
SQL
SELECT name,IF(stock =0,'sold out','in stock')AS availability
FROM products WHERE id IN(3,4);SELECT name, salary,CASE department_id WHEN1THEN salary *1.10WHEN2THEN salary *1.05ELSE salary ENDAS new_salary
FROM employees WHERE id IN(2,6,8,10);SELECTCASEWHEN1=1THEN1ELSE'one'ENDAS mixed;
Result
+-----------+--------------+
| name | availability |
+-----------+--------------+
| Pixel 10 | in stock |
| iPhone 17 | sold out |
+-----------+--------------+
+-------+-----------+-------------+
| name | salary | new_salary |
+-------+-----------+-------------+
| Ben | 142000.00 | 156200.0000 |
| Farid | 95000.00 | 99750.0000 |
| Hiro | 88000.00 | 88000.00 |
| Jonas | 91000.00 | 91000.00 |
+-------+-----------+-------------+
+-------+
| mixed |
+-------+
| 1 |
+-------+
What’s happening
IF(cond, a, b) is MySQL's two-way shorthand for CASE WHEN cond THEN a ELSE b END. It's not standard SQL (and don't confuse it with the IF statement in stored procedures).
The raise: engineering (1) gets 10%, sales (2) 5%, everyone else unchanged. Jonas's NULL department matches no WHEN and goes to ELSE salary.
Notice the display: 156200.0000 has four decimals while 88000.00 has two. The column's type is computed from all branches (salary * 1.10 is DECIMAL(…,4)), and each value is shown as it was produced.
Mixing types in branches forces a common type: THEN 1 ELSE 'one' makes the whole CASE a string, which is why 1 is printed left-aligned like text. Keep every branch the same type.
MySQL has hundreds of built-in functions, and interviews and day-to-day queries use a few dozen of them over and over: building and cutting strings, rounding numbers, and doing calendar arithmetic. They all work per row (unlike aggregates such as SUM, which work per group), and most of them return NULL if any argument is NULL.
The mental model is a toolbox you apply to each row as it passes. One rule matters more than any individual function: wrapping a column in a function in WHERE usually stops MySQL from using an index on that column, so put functions on the constant side of a comparison where you can.
SQL
SELECT name,
UPPER(name)AS shout,
SUBSTRING_INDEX(email,'@',-1)AS domain,
CONCAT(name,' <', email,'>')AS display,
DATE_FORMAT(created_at,'%b %Y')AS joined,
DATEDIFF('2025-12-31', created_at)AS days_to_year_end
FROM customers
WHERE id <=3;
Result
+-------+-------+-------------+---------------------------+----------+------------------+
| name | shout | domain | display | joined | days_to_year_end |
+-------+-------+-------------+---------------------------+----------+------------------+
| Rohit | ROHIT | example.com | Rohit <rohit@example.com> | Jan 2025 | 360 |
| Maya | MAYA | example.com | Maya <maya@example.com> | Jan 2025 | 345 |
| Liam | LIAM | example.com | Liam <liam@example.com> | Feb 2025 | 323 |
+-------+-------+-------------+---------------------------+----------+------------------+
3 rows in set (0.001 sec)
What’s happening
UPPER(name) returns an upper-case copy; the stored value is unchanged.
SUBSTRING_INDEX(email, '@', -1) returns everything after the last @. A positive count takes from the left (SUBSTRING_INDEX(email, '@', 1) is rohit), a negative one from the right. It's the go-to function for splitting on a delimiter.
CONCAT joins its arguments into one string. It's the standard way to concatenate in MySQL, because || means OR here (see the last example).
DATE_FORMAT(created_at, '%b %Y') formats a date with % codes: %b is the abbreviated month, %Y the four-digit year. %d is the day and %H:%i the time.
DATEDIFF(a, b) counts days from b to a, ignoring the time part. Rohit joined on 5 January, 360 days before 31 December.
CONCAT returns NULL if any argument is NULL, so Sofia's label disappears entirely. CONCAT_WS(separator, …) ("with separator") skips NULL arguments, giving just Sofia. Use it for addresses and full names with optional parts.
LENGTH counts bytes and CHAR_LENGTH counts characters. In utf8mb4, é takes 2 bytes, so café is 5 bytes but 4 characters, and the emoji takes 4 bytes. VARCHAR(n) limits are in characters; index-size limits are in bytes.
SQL strings are 1-based: SUBSTRING(s, 1, 5) is the first five characters. A negative start counts from the end, so SUBSTRING(s, -2) is the last two.
LOCATE('Book', 'ThinkBook 14') returns 6, the 1-based position, or 0 if not found (there's no -1 as in JavaScript). LPAD pads on the left to a length, good for fixed-width codes.
I ran these with the client set to utf8mb4. With the container's default latin1 client character set, the same query sends mangled bytes and the counts come out wrong, which is the kind of bug that only shows up with non-English data.
For exact values (DECIMAL and integer literals), ROUND rounds half away from zero: 2.5 → 3, 3.5 → 4, -2.5 → -3.
For floating-point values it uses the C library, which rounds half to even: ROUND(2.5e0) is 2. The same-looking number rounds differently depending on its type, one more reason to keep money in DECIMAL.
A negative number of places rounds to the left of the point: ROUND(1234.567, -2) is 1200. TRUNCATE just cuts digits off without rounding (1234.5).
CEIL rounds up and FLOOR rounds down, towards positive and negative infinity: FLOOR(-4.1) is -5, not -4.
AdvancedDate and time functions
SQL
SELECT id, ordered_at,DATE(ordered_at)AS just_date,YEAR(ordered_at)AS yr,
MONTHNAME(ordered_at)AS mon, DAYNAME(ordered_at)AS dow,
DATE_ADD(ordered_at,INTERVAL7DAY)AS plus_week, LAST_DAY(ordered_at)AS month_end
FROM orders WHERE id IN(1,6);SELECT TIMESTAMPDIFF(MONTH,'2025-01-31','2025-02-28')AS months,
TIMESTAMPDIFF(DAY,'2025-01-31','2025-02-28')AS days,
DATEDIFF('2025-03-01','2025-02-28')AS feb_days,'2025-01-31'+INTERVAL1MONTHAS jan31_plus_month;SELECT name, hired_on, TIMESTAMPDIFF(YEAR, hired_on,'2026-10-16')AS years_service
FROM employees WHERE id IN(1,9,10);SELECT STR_TO_DATE('16/10/2026','%d/%m/%Y')AS parsed,
CAST('2026-10-16'ASDATE)+INTERVAL1DAYAS tomorrow;
DATE() drops the time, YEAR(), MONTHNAME() and DAYNAME() pull out parts (EXTRACT(YEAR FROM …) is the standard form), and LAST_DAY() gives the last day of the month, handy for monthly reports.
DATE_ADD(d, INTERVAL 7 DAY) (or d + INTERVAL 7 DAY) does calendar arithmetic and keeps the time.
TIMESTAMPDIFF(unit, start, end) counts complete units: from 31 January to 28 February is 28 days but 0 months, because a full month would reach 31 February, which doesn't exist. Adding a month to 31 January, on the other hand, clamps to the last valid day, 2025-02-28.
That "complete units" rule is why TIMESTAMPDIFF(YEAR, hired_on, today) is the right way to compute years of service or age: Isla, hired 1 April 2024, has 2 complete years on 16 October 2026.
STR_TO_DATE parses text in a given format (the inverse of DATE_FORMAT), and CAST(… AS DATE) converts a standard YYYY-MM-DD string. NOW() returns the time the statement started (the same value for every row); SYSDATE() returns the time it's called.
AdvancedPitfall: functions on columns hide indexes
+ is always arithmetic in MySQL, never concatenation, so the string '5' is converted to the number 5 and 5 + 3 = 8.
CONCAT always produces a string, converting numbers to text: '5' and '3' join to '53' (shown left-aligned, as text).
|| in MySQL is logical OR by default, not concatenation as in standard SQL, PostgreSQL and Oracle. '5' and '3' are both non-zero, so '5' OR '3' is 1.
The warning is 1287 '|| as a synonym for OR' is deprecated and will be removed in a future release. Please use OR instead. The PIPES_AS_CONCAT SQL mode switches || to concatenation; otherwise use CONCAT.
You write a query in the order SELECT … FROM … WHERE … GROUP BY … HAVING … ORDER BY … LIMIT, but SQL defines its meaning in a different order: FROM (and joins) → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT. Each step takes the previous step's result and produces a new intermediate table. This "logical order" explains almost every "why can't I…" question about SQL.
Picture an assembly line. Raw rows enter at FROM; WHERE throws out individual rows; GROUP BY packs the survivors into boxes; HAVING throws out whole boxes; SELECT writes the label on each remaining row or box; DISTINCT removes identical labels; ORDER BY lines them up; and LIMIT takes the first few off the end of the belt. A station can only use what earlier stations have produced. (The optimizer is free to execute things in another order, such as pushing a filter into a join, as long as the result is the same.)
Each step only sees what earlier steps produced. Window functions are computed at step 5, after HAVING.SQL
SELECT c.city,COUNT(DISTINCT o.id)AS orders,SUM(oi.quantity * oi.unit_price)AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status<>'cancelled'GROUPBY c.city
HAVING revenue >150ORDERBY revenue DESCLIMIT2;
Result
+---------+--------+---------+
| city | orders | revenue |
+---------+--------+---------+
| Toronto | 3 | 2061.00 |
| Mumbai | 2 | 1164.00 |
+---------+--------+---------+
2 rows in set (0.000 sec)
What’s happening
FROM / JOIN: customers are joined to their orders and each order to its lines, giving one row per order line: 13 rows. Zara, with no orders, is not among them (inner join).
WHERE removes rows whose order is cancelled: order 4's single MacBook line goes, leaving 12 rows.
GROUP BY c.city packs the 12 rows into 4 groups: Toronto (Rohit's orders 1 and 2 and Noah's order 7, 6 lines), Mumbai (Maya's orders 3 and 8, 3 lines), London (Liam's order 5, 2 lines) and NULL (Sofia's order 6, 1 line).
HAVING needs each group's total, so SUM(quantity * unit_price) is computed per group: Toronto 2061.00, Mumbai 1164.00, NULL 249.00, London 127.00. revenue > 150 drops London.
SELECT produces the output columns for the 3 surviving groups. COUNT(DISTINCT o.id) counts orders, not lines (Toronto has 6 lines but 3 orders).
ORDER BY revenue DESC sorts Toronto, Mumbai, NULL, and LIMIT 2 keeps the first two. Sofia's NULL-city group is cut last of all.
AdvancedErrors the processing order explains
SQL
SELECT name, salary *12AS yearly FROM employees WHERE yearly >1000000;SELECTCOUNT(*)AS n FROM employees WHERECOUNT(*)>1;SELECT department_id AS dept,COUNT(*)AS headcount
FROM employees GROUPBY dept HAVING headcount >2ORDERBY headcount DESC;
Result
ERROR 1054 (42S22): Unknown column 'yearly' in 'where clause'
ERROR 1111 (HY000): Invalid use of group function
+------+-----------+
| dept | headcount |
+------+-----------+
| 1 | 4 |
| 2 | 3 |
+------+-----------+
What’s happening
WHERE (step 2) runs before SELECT (step 5), so the alias yearly doesn't exist yet: error 1054.
WHERE also runs before GROUP BY (step 3), so there are no groups to count yet. An aggregate in WHERE is error 1111, "Invalid use of group function". Aggregate conditions belong in HAVING.
The third query uses the aliases dept in GROUP BY and headcount in HAVING and ORDER BY, and MySQL accepts all three. By the logical order, GROUP BY and HAVING come before SELECT, so standard SQL (and PostgreSQL for HAVING) rejects them; MySQL resolves the alias anyway as an extension. ORDER BY with an alias is legal everywhere.
Engineering has 4 people, Sales 3, Support 2 and Jonas's NULL department 1, so HAVING headcount > 2 keeps two groups.
AdvancedDISTINCT happens before ORDER BY and LIMIT
SQL
SELECTDISTINCT department_id FROM employees ORDERBY department_id LIMIT2;