Database NotesRohit’s interview study guide
Chapter 15

Classic SQL Interview Problems

The problems interviewers actually set — Nth highest, duplicates, top N per group, running totals, gaps and islands, hierarchies, pivots and NULL traps — each solved more than one way and run on MySQL 9.7.

8 topics

#Ranking and Nth highest

Added

"Find the second highest salary" is probably the most-asked SQL question there is, and the reason is that it looks trivial but hides three decisions: what to do with ties, what to return when there is no answer, and which tool to reach for (an aggregate, LIMIT/OFFSET, or a window function). An interviewer learns more from how you handle the 142,000 that appears twice than from the query itself.

A good mental model is a podium with shared steps. DENSE_RANK puts everyone with the same score on the same step and the next score on the very next step (1, 2, 2, 3). RANK does the same but leaves a gap after a shared step, like Olympic medals (1, 2, 2, 4). ROW_NUMBER refuses to share and breaks ties arbitrarily (1, 2, 3, 4). "Nth highest salary" almost always means the Nth distinct value — the DENSE_RANK step — and saying that out loud before writing anything is half the answer.

Every problem in this chapter uses the site's sample schema (see The sample schema used on this site) or a small purpose-built table shown with the question. The salaries you'll see most are these ten:

EmployeeSalaryEmployeeSalary
Asha185,000Grace99,000
Ben142,000Farid95,000
Chloe142,000Jonas91,000
Elena130,000Hiro88,000
Dev118,000Isla72,000
Second highest salary — and what if there isn't one?

Write a query that returns the second highest salary from employees as a single column called second_highest. If there is no second highest salary (for example in a department with fewer than two distinct salaries), it should return NULL, not an empty result.

employees (salary column, highest first)
185000.00  142000.00  142000.00  130000.00  118000.00
 99000.00   95000.00   91000.00   88000.00   72000.00
Show answer

The answer is 142,000. The simplest query that's correct for ties and returns NULL when there's no answer is "the highest salary that's lower than the highest salary":

SQL
SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
Result
+----------------+
| second_highest |
+----------------+
|      142000.00 |
+----------------+
What’s happening
  1. The subquery (SELECT MAX(salary) FROM employees) runs once and returns 185000.00 — it's not correlated, so MySQL evaluates it a single time.
  2. The WHERE keeps every row with salary < 185000: nine rows, from Ben's 142000 down to Isla's 72000. Asha is the only row removed.
  3. MAX(salary) over those nine rows is 142000.00. Ben and Chloe both have it, but MAX returns a value, not a row, so the tie can't produce two results.
  4. The NULL requirement comes for free: an aggregate without GROUP BY always returns exactly one row. If the WHERE matched nothing, MAX over an empty set is NULL.
  5. The limitation is that it only generalises to "third highest" by nesting another level, which gets ugly fast — that's where the next two forms win.

Alternative 1: DISTINCT + LIMIT/OFFSET, wrapped so it returns NULL. Skip one distinct salary and take the next. On its own this returns no row when there's no second salary, so the problem's trick is to wrap it in a scalar subquery. Here it's run for Research (department 4), which has no employees, to show the difference:

SQL
SELECT DISTINCT salary FROM employees
WHERE department_id = 4
ORDER BY salary DESC LIMIT 1 OFFSET 1;

SELECT (SELECT DISTINCT salary FROM employees
        WHERE department_id = 4
        ORDER BY salary DESC LIMIT 1 OFFSET 1) AS second_highest;
Result
Empty set

+----------------+
| second_highest |
+----------------+
|           NULL |
+----------------+
What’s happening
  1. SELECT DISTINCT salary … ORDER BY salary DESC produces the distinct salaries highest first. For the whole table that's 185000, 142000, 130000, … — the duplicate 142000 collapses to one.
  2. LIMIT 1 OFFSET 1 skips one row and returns the next, so for the whole table it returns 142000.00.
  3. For Research there are no rows at all, so the bare query returns an empty set — zero rows, which an application or a test harness sees as "no answer", not as NULL.
  4. Wrapping it as a scalar subquery in an outer SELECT with no FROM changes that: the outer query always produces one row, and a scalar subquery that finds no row evaluates to NULL.
  5. Without DISTINCT this form breaks on ties — see the next question.

Alternative 2: DENSE_RANK (MySQL 8.0+). Number the distinct salaries and pick rank 2:

SQL
WITH ranked AS (
  SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
  FROM employees
)
SELECT DISTINCT salary AS second_highest FROM ranked WHERE rnk = 2;
Result
+----------------+
| second_highest |
+----------------+
|      142000.00 |
+----------------+
What’s happening
  1. The window function gives every row a rank: Asha 1, Ben 2, Chloe 2, Elena 3… Ties share a rank and DENSE_RANK never skips a number.
  2. WHERE rnk = 2 keeps Ben's and Chloe's rows — two rows with the same salary.
  3. DISTINCT collapses them to one value. Without it you'd return 142000.00 twice.
  4. The filter has to sit in an outer query (a CTE or derived table): window functions are computed after WHERE, so WHERE DENSE_RANK() OVER (…) = 2 isn't allowed in the same SELECT.
  5. Like the LIMIT form, this returns an empty set when there's no rank 2, so wrap it the same way if NULL is required.
ApproachTiesGeneralises to N"No answer"Needs
MAX(…) WHERE salary < MAXCorrectOnly by nestingNULL automaticallyAny version
DISTINCT … LIMIT 1 OFFSET n-1Correct only with DISTINCTYes (literal offset)Empty set unless wrappedAny version
DENSE_RANK()CorrectYes, and per group with PARTITION BYEmpty set unless wrappedMySQL 8.0+

Learn more: ROW_NUMBER, RANK and DENSE_RANK and Subqueries.

Spot the bug: the "third highest salary" that returns 142,000

A colleague wrote this to find the third highest salary. What does it return, and what's wrong with it?

SQL
SELECT salary FROM employees
ORDER BY salary DESC
LIMIT 1 OFFSET 2;
Show answer

It returns 142,000, which is the second highest salary. The third highest distinct salary is 130,000. The bug is the missing DISTINCT: OFFSET counts rows, and the tie between Ben and Chloe uses up two of them.

SQL
SELECT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 2;
SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 2;
Result
+-----------+
| salary    |
+-----------+
| 142000.00 |
+-----------+

+-----------+
| salary    |
+-----------+
| 130000.00 |
+-----------+
What’s happening
  1. Sorted highest first, the rows are 185000, 142000, 142000, 130000, …. OFFSET 2 skips the first two rows — Asha's and one of the two 142,000 rows.
  2. The next row is the other 142,000, so the buggy query returns 142000.00.
  3. With DISTINCT, the sorted list becomes 185000, 142000, 130000, … — duplicates are removed before ORDER BY and LIMIT are applied (logical order: SELECT DISTINCT, then ORDER BY, then LIMIT).
  4. Skipping two values now lands on 130000.00, the correct third highest.
  5. The bug is invisible on test data without ties, which is exactly why interviewers put a tie in the data. Asking "do ties count once?" before you write the query shows you've seen it.

Learn more: LIMIT and OFFSET and DISTINCT.

Nth highest salary as a function — why does LIMIT N-1 fail?

Write a stored function nth_highest_salary(n INT) that returns the Nth highest distinct salary, or NULL if there isn't one. This first attempt doesn't even compile — what's the error, and how do you fix it?

SQL
CREATE FUNCTION nth_highest_salary(n INT) RETURNS DECIMAL(10,2)
READS SQL DATA
RETURN (SELECT DISTINCT salary FROM employees
        ORDER BY salary DESC LIMIT n - 1, 1);
Show answer

MySQL's LIMIT only accepts integer literals, ? placeholders, or stored-program variables and parameters — not expressions. n - 1 is an expression, so it's a syntax error:

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 '- 1, 1)' at line 3

The fix is to compute the offset into a local variable first, then use the variable in LIMIT:

SQL
DELIMITER //
CREATE FUNCTION nth_highest_salary(n INT) RETURNS DECIMAL(10,2)
READS SQL DATA
BEGIN
  DECLARE skip INT DEFAULT n - 1;
  RETURN (SELECT DISTINCT salary FROM employees
          ORDER BY salary DESC LIMIT skip, 1);
END//
DELIMITER ;

SELECT nth_highest_salary(1) AS n1, nth_highest_salary(2) AS n2,
       nth_highest_salary(3) AS n3, nth_highest_salary(9) AS n9;
Result
+-----------+-----------+-----------+----------+
| n1        | n2        | n3        | n9       |
+-----------+-----------+-----------+----------+
| 185000.00 | 142000.00 | 130000.00 | 72000.00 |
+-----------+-----------+-----------+----------+
What’s happening
  1. DELIMITER // tells the mysql client not to end the statement at the first ; inside the function body. It's a client feature, not SQL; GUI tools and drivers don't need it.
  2. DECLARE skip INT DEFAULT n - 1 evaluates the expression once and stores it. LIMIT skip, 1 is legal because skip is a local variable, which MySQL allows in LIMIT inside stored programs (since 5.5).
  3. LIMIT skip, 1 is the two-argument form: offset first, then row count. For n = 3, it skips two distinct salaries (185000, 142000) and returns 130000.00.
  4. There are only nine distinct salaries (ten employees, one tie), so n9 is the lowest, 72000.00, and nth_highest_salary(10) would find no row — the RETURN (subquery) then returns NULL, which is what the spec asks for.
  5. READS SQL DATA declares that the function reads but doesn't modify data. Leave it out and, with binary logging on (the default since 8.0), CREATE FUNCTION fails with ERROR 1418 (HY000): This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA in its declaration and binary logging is enabled.

Alternative 1: a prepared statement. Outside a stored program, LIMIT ? takes a placeholder, so application code (or a session) can bind the offset:

SQL
SET @skip = 2;
PREPARE stmt FROM 'SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT ?, 1';
EXECUTE stmt USING @skip;
DEALLOCATE PREPARE stmt;
Result
+-----------+
| salary    |
+-----------+
| 130000.00 |
+-----------+
What’s happening
  1. PREPARE parses the statement once with ? standing in for the offset. It's the same mechanism drivers like mysql2 use for execute().
  2. EXECUTE … USING @skip binds 2, so the query skips two distinct salaries and returns 130000.00.
  3. This is how you'd do it from Node: compute n - 1 in JavaScript and pass it as a parameter — never by concatenating it into the SQL string.

Alternative 2: no LIMIT, no window functions. The pre-8.0 classic: a salary is the Nth highest if exactly N - 1 distinct salaries are higher than it.

SQL
SELECT DISTINCT e1.salary
FROM employees e1
WHERE 3 - 1 = (SELECT COUNT(DISTINCT e2.salary)
               FROM employees e2
               WHERE e2.salary > e1.salary);
Result
+-----------+
| salary    |
+-----------+
| 130000.00 |
+-----------+
What’s happening
  1. For each row e1, the correlated subquery counts the distinct salaries strictly greater than e1.salary.
  2. For Elena (130000) the higher distinct salaries are 185000 and 142000, so the count is 2, which equals 3 - 1. She's kept.
  3. For Ben and Chloe (142000) the count is 1; for Dev (118000) it's 3. Neither matches.
  4. DISTINCT in the outer query matters when several people share the Nth salary — try it with 2 - 1 and you'd get Ben and Chloe's 142000 twice without it.
  5. The cost: the subquery runs once per row, so it's O(n²) without an index on salary. It's the answer to "how would you do it without window functions?", not the one to ship.

The window-function version for any N is SELECT DISTINCT salary FROM (SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk FROM employees) AS r WHERE rnk = 3, which also returned 130000.00.

Learn more: Stored functions and SQL injection and prepared statements.

Rank the salaries three ways — predict ROW_NUMBER, RANK and DENSE_RANK

Predict the three ranking columns for the top six salaries.

SQL
SELECT name, salary,
       ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num,
       RANK()       OVER (ORDER BY salary DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY salary DESC) AS dense
FROM employees
ORDER BY salary DESC, name
LIMIT 6;
Show answer

Ben and Chloe tie on 142,000. ROW_NUMBER gives them 2 and 3, RANK gives both 2 and then jumps to 4, DENSE_RANK gives both 2 and continues with 3.

Result
+-------+-----------+---------+-----+-------+
| name  | salary    | row_num | rnk | dense |
+-------+-----------+---------+-----+-------+
| Asha  | 185000.00 |       1 |   1 |     1 |
| Ben   | 142000.00 |       2 |   2 |     2 |
| Chloe | 142000.00 |       3 |   2 |     2 |
| Elena | 130000.00 |       4 |   4 |     3 |
| Dev   | 118000.00 |       5 |   5 |     4 |
| Grace |  99000.00 |       6 |   6 |     5 |
+-------+-----------+---------+-----+-------+
What’s happening
  1. All three functions sort by salary DESC inside the window. Asha is first everywhere: 1, 1, 1.
  2. ROW_NUMBER numbers rows 1, 2, 3, … with no sharing. Ben and Chloe tie, so which of them gets 2 is not defined by the query — here Ben got 2, but nothing guarantees it. Add a tiebreaker (ORDER BY salary DESC, id) when you need a stable result.
  3. RANK is "1 + the number of rows strictly ahead of me". Ben and Chloe have one row ahead (Asha), so both are 2. Elena has three rows ahead, so she's 4 — the gap is the "missing" 3.
  4. DENSE_RANK is "1 + the number of distinct values ahead of me". Elena has two distinct salaries ahead, so she's 3. No gaps, ever.
  5. The outer ORDER BY salary DESC, name only controls display order; it doesn't change how the window functions numbered the rows.

Before MySQL 8.0 there were no window functions, and you'd compute ranks with correlated subqueries. Both give the same numbers as above:

SQL
SELECT e1.name, e1.salary,
       (SELECT COUNT(DISTINCT e2.salary) FROM employees e2
        WHERE e2.salary >= e1.salary) AS dense,
       (SELECT COUNT(*) FROM employees e2
        WHERE e2.salary > e1.salary) + 1 AS rnk
FROM employees e1
ORDER BY e1.salary DESC, e1.name
LIMIT 6;
Result
+-------+-----------+-------+------+
| name  | salary    | dense | rnk  |
+-------+-----------+-------+------+
| Asha  | 185000.00 |     1 |    1 |
| Ben   | 142000.00 |     2 |    2 |
| Chloe | 142000.00 |     2 |    2 |
| Elena | 130000.00 |     3 |    4 |
| Dev   | 118000.00 |     4 |    5 |
| Grace |  99000.00 |     5 |    6 |
+-------+-----------+-------+------+
What’s happening
  1. dense counts distinct salaries greater than or equal to mine. For Elena: 185000, 142000, 130000 → 3.
  2. rnk counts rows strictly greater than mine and adds one. For Elena: Asha, Ben, Chloe → 3 + 1 = 4.
  3. The definitions are exactly the ones in the walkthrough above, written out by hand — which is a good way to show you understand what the window functions compute.
  4. Each subquery runs once per outer row, so this is quadratic; with window functions MySQL sorts once and assigns ranks in a single pass.

NTILE(4) OVER (ORDER BY salary DESC) is the related "bucket" function: on this data it put Asha, Ben and Chloe in quartile 1 and Hiro and Isla in quartile 4, because ten rows don't split evenly and the earlier buckets get the extra rows.

Learn more: ROW_NUMBER, RANK and DENSE_RANK and Correlated subqueries.

Median salary — overall and per department

MySQL has no MEDIAN() function. Find the median salary of all employees, then the median per department.

Show answer

The overall median is 108,500: with ten salaries, it's the average of the 5th and 6th (99,000 and 118,000). The trick is to number the rows, count them, and average the one or two middle rows:

SQL
WITH ordered AS (
  SELECT salary,
         ROW_NUMBER() OVER (ORDER BY salary) AS rn,
         COUNT(*)     OVER ()                AS cnt
  FROM employees
)
SELECT AVG(salary) AS median
FROM ordered
WHERE rn IN (FLOOR((cnt + 1) / 2), CEIL((cnt + 1) / 2));
Result
+---------------+
| median        |
+---------------+
| 108500.000000 |
+---------------+
What’s happening
  1. ROW_NUMBER() OVER (ORDER BY salary) numbers the salaries lowest first: Isla 1 (72,000) … Asha 10 (185,000).
  2. COUNT(*) OVER () — an empty window — puts the total row count, 10, on every row, so the outer query can use it without a second query.
  3. With cnt = 10, (cnt + 1) / 2 is 5.5. FLOOR gives 5 and CEIL gives 6, so rows 5 and 6 are kept: Grace (99,000) and Dev (118,000).
  4. AVG of those two is 108500. For an odd count, say 9, (9 + 1) / 2 is exactly 5, FLOOR and CEIL agree, and AVG of one row is just that row — so one formula covers both cases.
  5. Compare the mean: AVG(salary) over everyone is 116200. Asha's 185,000 pulls the mean up but barely moves the median, which is why medians are used for salaries.

Per department is the same query with PARTITION BY on both window functions:

SQL
WITH ordered AS (
  SELECT department_id, salary,
         ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary) AS rn,
         COUNT(*)     OVER (PARTITION BY department_id)                 AS cnt
  FROM employees
  WHERE department_id IS NOT NULL
)
SELECT department_id, AVG(salary) AS median, MIN(cnt) AS employees
FROM ordered
WHERE rn IN (FLOOR((cnt + 1) / 2), CEIL((cnt + 1) / 2))
GROUP BY department_id;
Result
+---------------+---------------+-----------+
| department_id | median        | employees |
+---------------+---------------+-----------+
|             1 | 142000.000000 |         4 |
|             2 |  99000.000000 |         3 |
|             3 |  80000.000000 |         2 |
+---------------+---------------+-----------+
What’s happening
  1. PARTITION BY department_id restarts the numbering and the count for each department, so every row knows its position and its department's size.
  2. Engineering has 4 salaries (118k, 142k, 142k, 185k). Rows 2 and 3 are both 142,000, so the median is 142000.
  3. Sales has 3 (95k, 99k, 130k); (3 + 1) / 2 = 2, so only row 2 is kept: 99000.
  4. Support has 2 (72k, 88k); rows 1 and 2 are averaged: 80000.
  5. MIN(cnt) is just a way to show the group size: cnt is the same on every row of a group, and an aggregate is needed under ONLY_FULL_GROUP_BY. Jonas (no department) is filtered out; without the filter he'd form his own NULL group.

Learn more: Window functions: OVER and PARTITION BY.

#Duplicates

Added

Duplicate rows are what you get when a table has a surrogate id but nothing stops the real identity — an email, an order number, a (user, day) pair — from repeating. A double-clicked sign-up button, a retried import, a race between two requests: each inserts a perfectly valid row with a new id. Interviewers ask about duplicates because the full answer has three parts: find them, remove them while keeping the right copy, and stop them coming back with a constraint.

The mental model is sorting the post into pigeonholes. GROUP BY email puts every row into the hole for its email; any hole with more than one letter is a duplicate. To clean up you keep one letter per hole — the oldest, the newest, the most complete — and throw the rest away. To prevent it you make the holes one-letter-only: a UNIQUE index. One subtlety decides what "the same email" means: MySQL compares strings using the column's collation, and the default utf8mb4_0900_ai_ci is case- and accent-insensitive, so Cara@Example.com and cara@example.com land in the same hole.

All the questions in this topic use this purpose-built table:

SQL
CREATE TABLE signups (
  id        INT PRIMARY KEY,
  email     VARCHAR(100) NOT NULL,
  name      VARCHAR(50)  NOT NULL,
  signed_up DATE         NOT NULL
);
-- … eight rows inserted …
SELECT * FROM signups;
Result
+----+------------------+----------+------------+
| id | email            | name     | signed_up  |
+----+------------------+----------+------------+
|  1 | asha@example.com | Asha     | 2025-01-03 |
|  2 | ben@example.com  | Ben      | 2025-01-04 |
|  3 | asha@example.com | Asha     | 2025-01-09 |
|  4 | cara@example.com | Cara     | 2025-01-10 |
|  5 | ben@example.com  | Benjamin | 2025-01-12 |
|  6 | asha@example.com | Asha     | 2025-02-01 |
|  7 | Cara@Example.com | Cara     | 2025-02-02 |
|  8 | dan@example.com  | Dan      | 2025-02-03 |
+----+------------------+----------+------------+
What’s happening
  1. id is the primary key, so every row is technically unique — the duplicates are in email, the column that identifies a person.
  2. Asha signed up three times (ids 1, 3, 6) and Ben twice (2, 5, once as "Benjamin"), so a "duplicate" isn't always an identical row: here only the email repeats.
  3. Rows 4 and 7 differ only in letter case. Whether they count as duplicates depends on the collation, which is the trap in the second question below.
  4. Dan (8) is the only clean row.
Find the duplicate emails — and then the full duplicate rows
  1. List every email that appears more than once, with how many times.
  2. Then list the complete rows (id, name, date) of every duplicated email, so someone can decide which to keep.
Show answer

For part 1, group by the column that should be unique and keep groups with more than one row:

SQL
SELECT email, COUNT(*) AS copies
FROM signups
GROUP BY email
HAVING COUNT(*) > 1;
Result
+------------------+--------+
| email            | copies |
+------------------+--------+
| asha@example.com |      3 |
| ben@example.com  |      2 |
| cara@example.com |      2 |
+------------------+--------+
What’s happening
  1. GROUP BY email builds one group per email. Because the collation is case-insensitive, rows 4 and 7 go into the same group — more on that in the next question.
  2. COUNT(*) counts rows per group: asha 3, ben 2, cara 2, dan 1.
  3. HAVING filters groups, after aggregation. WHERE COUNT(*) > 1 would be an error, because WHERE runs before any grouping happens.
  4. The query can't show id or name: those differ inside a group, and under ONLY_FULL_GROUP_BY MySQL rejects non-aggregated columns that aren't grouped. That's why part 2 needs a different shape.

For part 2, the cleanest modern answer is a window count: every row learns how many rows share its email, without being collapsed.

SQL
SELECT id, email, name, signed_up, copies
FROM (
  SELECT s.*, COUNT(*) OVER (PARTITION BY email) AS copies
  FROM signups s
) AS t
WHERE copies > 1
ORDER BY email, id;
Result
+----+------------------+----------+------------+--------+
| id | email            | name     | signed_up  | copies |
+----+------------------+----------+------------+--------+
|  1 | asha@example.com | Asha     | 2025-01-03 |      3 |
|  3 | asha@example.com | Asha     | 2025-01-09 |      3 |
|  6 | asha@example.com | Asha     | 2025-02-01 |      3 |
|  2 | ben@example.com  | Ben      | 2025-01-04 |      2 |
|  5 | ben@example.com  | Benjamin | 2025-01-12 |      2 |
|  4 | cara@example.com | Cara     | 2025-01-10 |      2 |
|  7 | Cara@Example.com | Cara     | 2025-02-02 |      2 |
+----+------------------+----------+------------+--------+
What’s happening
  1. COUNT(*) OVER (PARTITION BY email) is the same count as part 1, but as a window function it's attached to every row instead of collapsing the group.
  2. The derived table t has all eight rows, each with its copies value: Dan's is 1.
  3. The outer WHERE copies > 1 removes Dan. It must be in an outer query because window functions are evaluated after WHERE.
  4. Sorting by email, id puts each set of duplicates together, oldest first — exactly the view you want before deciding which to delete.

Alternative: join back to the grouped list (works on MySQL 5.7 too):

SQL
SELECT s.*
FROM signups s
JOIN (SELECT email FROM signups
      GROUP BY email HAVING COUNT(*) > 1) d
  ON d.email = s.email
ORDER BY s.email, s.id;

The result is the same seven rows without the copies column.

What’s happening
  1. The derived table d is part 1's query reduced to the email column: asha, ben, cara.
  2. Joining signups to it keeps only rows whose email is in that list — a semi-join written as an inner join, safe here because d has one row per email, so nothing is multiplied.
  3. The trade-off: this scans signups twice (once to group, once to join), where the window version scans it once. On a big table with an index on email, both are fine; without one, the window version is usually cheaper.

A third form you'll see is a self-join, SELECT DISTINCT a.email FROM signups a JOIN signups b ON a.email = b.email AND a.id <> b.id. It returns the same three emails, but it compares every pair of rows within each group (Asha's three rows make six pairs), so it scales badly when one value has many copies.

Learn more: HAVING vs WHERE and Window functions: OVER and PARTITION BY.

Predict: are Cara@Example.com and cara@example.com duplicates?

The column is VARCHAR(100) with the table's default character set, utf8mb4. Predict both results.

SQL
SELECT id, email FROM signups WHERE email = 'cara@example.com';

SELECT email COLLATE utf8mb4_bin AS email_exact, COUNT(*)
FROM signups
GROUP BY email_exact
HAVING COUNT(*) > 1;
Show answer

Yes, by default they're equal — searching for the lower-case spelling finds both rows — but with a binary collation they're different strings, and Cara drops out of the duplicates list.

Result
+----+------------------+
| id | email            |
+----+------------------+
|  4 | cara@example.com |
|  7 | Cara@Example.com |
+----+------------------+

+------------------+----------+
| email_exact      | COUNT(*) |
+------------------+----------+
| asha@example.com |        3 |
| ben@example.com  |        2 |
+------------------+----------+
What’s happening
  1. SELECT COLLATION(email) FROM signups returns utf8mb4_0900_ai_ci, MySQL 8.0+'s default. ai means accent-insensitive and ci case-insensitive, so C and c compare equal and the WHERE matches row 7 as well as row 4. (When a column is compared with a literal, the column's collation wins.)
  2. That's why the plain GROUP BY email in the previous question found three duplicate emails, and why a UNIQUE index on email would treat rows 4 and 7 as a collision.
  3. COLLATE utf8mb4_bin compares the raw code points instead. Now Cara@Example.com and cara@example.com are different groups of one row each, and only Asha and Ben remain.
  4. Which answer is right is a business question. For emails, case-insensitive is usually what you want (the domain part is case-insensitive, and users mistype case), so the default collation is helpful — but the safest design is to normalise on the way in (LOWER(TRIM(email))) so there's only one spelling stored.
  5. MySQL 5.7's default was latin1 / utf8mb4_general_ci depending on configuration; general_ci is also case-insensitive, so the same surprise applies on older servers.

Learn more: Strings: CHAR, VARCHAR, TEXT and character sets.

Delete the duplicates, keeping the lowest id — why does the obvious query fail?

Delete every duplicate signup so that only the row with the lowest id per email survives. The obvious query is below. What happens when you run it, and what should you write instead?

SQL
DELETE FROM signups
WHERE id NOT IN (SELECT MIN(id) FROM signups GROUP BY email);
Show answer

MySQL refuses it. You can't read from the table you're deleting from in a subquery:

Result
ERROR 1093 (HY000): You can't specify target table 'signups' for update in FROM clause

The idiomatic MySQL fix is a multi-table DELETE with a self-join: delete every row for which a row with the same email and a smaller id exists.

SQL
START TRANSACTION;

DELETE s1
FROM signups s1
JOIN signups s2
  ON s1.email = s2.email
 AND s1.id > s2.id;

SELECT * FROM signups;
ROLLBACK;
Result
Query OK, 4 rows affected

+----+------------------+------+------------+
| id | email            | name | signed_up  |
+----+------------------+------+------------+
|  1 | asha@example.com | Asha | 2025-01-03 |
|  2 | ben@example.com  | Ben  | 2025-01-04 |
|  4 | cara@example.com | Cara | 2025-01-10 |
|  8 | dan@example.com  | Dan  | 2025-02-03 |
+----+------------------+------+------------+
What’s happening
  1. Error 1093 exists because MySQL would be reading signups while deleting from it, and the rows the subquery sees could change under it. PostgreSQL allows the original query (on PostgreSQL 18 the same DELETE … NOT IN (SELECT MIN(id) …) ran and deleted the duplicate); MySQL doesn't.
  2. In the self-join, s1 is the row that might be deleted and s2 is "another row with the same email". s1.id > s2.id matches when there's an older copy. Asha's id 3 matches id 1; id 6 matches ids 1 and 3.
  3. DELETE s1 (naming the alias) says which side of the join to delete from. Id 6 joined twice, but a row is only deleted once, so 4 rows affected is ids 3, 5, 6 and 7.
  4. Id 7 (Cara@Example.com) went too, because the case-insensitive collation says it equals id 4's email. Decide that before you run it.
  5. Wrapping it in START TRANSACTION … ROLLBACK let us inspect the result and undo it. In real life you'd check, then COMMIT.

Alternative 1: hide the subquery inside a derived table. Wrapping the subquery one level deeper makes MySQL materialise it into a temporary result first, so it no longer reads signups during the delete:

SQL
DELETE FROM signups
WHERE id NOT IN (
  SELECT keep_id FROM (
    SELECT MIN(id) AS keep_id FROM signups GROUP BY email
  ) AS keepers
);
Result
Query OK, 4 rows affected
What’s happening
  1. The inner query computes the ids to keep: 1, 2, 4, 8.
  2. Because it's a derived table (AS keepers), MySQL materialises it before the delete starts. The MySQL manual documents this as the exception to error 1093: it applies only "if … that derived table is materialized rather than merged into the outer query".
  3. NOT IN (1, 2, 4, 8) is safe here because MIN(id) from a NOT NULL primary key can't be NULL. With a nullable column, NOT IN plus a NULL deletes nothing — see Tricky NULL and JOIN puzzles.
  4. It leaves the same four rows as the self-join. It reads more clearly as "keep these", which some reviewers prefer.

Alternative 2: ROW_NUMBER (MySQL 8.0+). Number the rows inside each email and delete everything after the first. This is the most flexible form, because the ORDER BY decides which copy survives:

SQL
WITH ranked AS (
  SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
  FROM signups
)
DELETE signups
FROM signups JOIN ranked USING (id)
WHERE ranked.rn > 1;
Result
Query OK, 4 rows affected
What’s happening
  1. The CTE numbers rows per email by id: Asha's 1, 3, 6 get rn 1, 2, 3; Ben's 2, 5 get 1, 2; and so on.
  2. The multi-table DELETE joins signups to the CTE on id and deletes rows with rn > 1: ids 3, 6, 5 and 7.
  3. MySQL allows a CTE before DELETE (8.0+), and the CTE is materialised, so there's no error 1093.
  4. To keep the newest copy instead, change only the window's order: ORDER BY signed_up DESC, id DESC. Run with that order, the survivors were ids 5, 6, 7 and 8 — Benjamin, Asha's February row, Cara@Example.com and Dan.
ApproachChooses the survivor byVersionNotes
DELETE s1 … JOIN s2 ON … s1.id > s2.idLowest id (flip to < for highest)AnyJoins every pair inside a group; fine for a few copies each
NOT IN (SELECT … FROM (derived))Whatever the inner aggregate picksAnyRelies on materialisation; careful with NULL
ROW_NUMBER() + DELETE … JOINAny ordering you like8.0+Most flexible; one pass to number rows

Learn more: UPDATE and DELETE with joins and Safe updates and the dangers of a missing WHERE.

Stop duplicates coming back

You want a UNIQUE index on signups.email. What happens if you add it before cleaning up, and how should the application handle a sign-up for an email that already exists once the index is in place?

Show answer

The index can't be built while duplicates exist: MySQL checks every existing row and stops at the first collision.

SQL
ALTER TABLE signups ADD UNIQUE KEY uq_email (email);
Result
ERROR 1062 (23000): Duplicate entry 'cara@example.com' for key 'signups.uq_email'

After the clean-up from the previous question, the same ALTER TABLE succeeds. Then a repeat sign-up is either an error, an upsert, or silently ignored — your choice:

SQL
INSERT INTO signups VALUES (9, 'ASHA@example.com', 'Asha R', '2025-03-01');

INSERT INTO signups VALUES (9, 'ASHA@example.com', 'Asha R', '2025-03-01') AS new
  ON DUPLICATE KEY UPDATE name = new.name, signed_up = new.signed_up;

SELECT * FROM signups;
Result
ERROR 1062 (23000): Duplicate entry 'ASHA@example.com' for key 'signups.uq_email'

Query OK, 2 rows affected

+----+------------------+--------+------------+
| id | email            | name   | signed_up  |
+----+------------------+--------+------------+
|  1 | asha@example.com | Asha R | 2025-03-01 |
|  2 | ben@example.com  | Ben    | 2025-01-04 |
|  4 | cara@example.com | Cara   | 2025-01-10 |
|  8 | dan@example.com  | Dan    | 2025-02-03 |
+----+------------------+--------+------------+
What’s happening
  1. The failed ALTER TABLE names cara@example.com — the case-only duplicate — because the unique index uses the column's case-insensitive collation, exactly like GROUP BY did. Nothing is changed when it fails.
  2. With the index in place, a plain INSERT of ASHA@example.com fails with error 1062 too. That's the constraint doing its job: the database, not the application, is now the last line of defence against races.
  3. INSERT … AS new ON DUPLICATE KEY UPDATE turns the collision into an update of the existing row. new.name refers to the value you tried to insert. 2 rows affected is MySQL's convention for "an existing row was updated" (1 means inserted, 0 means nothing changed).
  4. Row 1 kept its id and its stored email spelling, but took the new name and date. Id 9 was never created.
  5. The row alias form (AS new) is MySQL 8.0.19+. Before that you'd write name = VALUES(name), which still works but has been deprecated since 8.0.20 and logs a warning.

INSERT IGNORE is the third option: it turns the error into a warning and inserts nothing. Run with ben@example.com, it returned Query OK, 0 rows affected, 1 warning, and SHOW WARNINGS showed 1062 | Duplicate entry 'ben@example.com' for key 'signups.uq_email'. Be careful: IGNORE also downgrades other errors (like bad values in strict mode) to warnings.

Learn more: Upserts: ON DUPLICATE KEY UPDATE and INSERT IGNORE and REPLACE.

#Top N and per-group problems

Added

"Greatest-n-per-group" is the family of questions that ask for whole rows that win inside their group: the highest-paid person in each department, each customer's latest order, the top three salaries per department. They're hard for one reason: GROUP BY collapses each group to one row of aggregates, so it can tell you the highest salary per department but not who earns it. Every solution is a way of getting the winning value and keeping the row it came from.

The mental model is a heat in a race. GROUP BY gives you the winning time per heat but throws away the runners. To get the runners back you either (a) take the winning times and look up who ran them (join back to an aggregate), (b) give every runner a placing within their heat and keep placings 1 to N (window functions), (c) keep a runner only if nobody in their heat was faster (anti-join or correlated subquery), or (d) for each heat, go and fetch its top N (LATERAL). All four appear below, with what they do on ties.

Highest-paid employee in each department, ties included

Return each department's name, the employee(s) with the highest salary in it, and the salary. If two people tie for the top, return both. Employees without a department are ignored.

employees joined to departments
Engineering: Asha 185000, Ben 142000, Chloe 142000, Dev 118000
Sales:       Elena 130000, Grace 99000, Farid 95000
Support:     Hiro 88000, Isla 72000
Research:    (nobody)          — and Jonas 91000 has no department
Show answer

The winners are Asha, Elena and Hiro. The most portable answer joins each employee to their department's maximum with a row-value IN:

SQL
SELECT d.name AS department, e.name AS employee, e.salary
FROM employees e
JOIN departments d ON d.id = e.department_id
WHERE (e.department_id, e.salary) IN (
  SELECT department_id, MAX(salary)
  FROM employees
  GROUP BY department_id
)
ORDER BY d.name;
Result
+-------------+----------+-----------+
| department  | employee | salary    |
+-------------+----------+-----------+
| Engineering | Asha     | 185000.00 |
| Sales       | Elena    | 130000.00 |
| Support     | Hiro     |  88000.00 |
+-------------+----------+-----------+
What’s happening
  1. The subquery is the GROUP BY that "knows the winning time": (1, 185000), (2, 130000), (3, 88000) and (NULL, 91000) for Jonas's missing department.
  2. (e.department_id, e.salary) IN (…) is a row constructor comparison: both values must match the same pair. Asha (1, 185000) matches; Ben (1, 142000) doesn't.
  3. Jonas's pair (NULL, 91000) never matches anything — NULL = NULL is unknown — and the inner join to departments would drop him anyway.
  4. Ties are kept automatically: if Ben earned 185,000 too, his pair would match and he'd appear alongside Asha.
  5. Research has no employees, so it has no maximum and doesn't appear. If the question wanted "every department, even empty ones", you'd start from departments with a LEFT JOIN.

Alternative 1: RANK() per department (MySQL 8.0+). The most readable version, and the one that generalises to "top 3":

SQL
WITH ranked AS (
  SELECT e.*, RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk
  FROM employees e
)
SELECT d.name AS department, r.name AS employee, r.salary
FROM ranked r JOIN departments d ON d.id = r.department_id
WHERE r.rnk = 1
ORDER BY d.name;

The result is the same three rows.

What’s happening
  1. PARTITION BY department_id restarts the ranking per department; ORDER BY salary DESC puts the highest first, so each department's top earner gets rnk = 1.
  2. RANK (not ROW_NUMBER) is what keeps ties: two people on the same top salary would both be 1.
  3. Filtering happens in the outer query because WHERE can't see window results in the same SELECT.
  4. One scan and one sort per partition, which usually beats the join-back form on large tables.

Alternative 2: "nobody in my department earns more" — an anti-join with LEFT JOIN … IS NULL:

SQL
SELECT d.name AS department, e.name AS employee, e.salary
FROM employees e
JOIN departments d ON d.id = e.department_id
LEFT JOIN employees richer
  ON richer.department_id = e.department_id
 AND richer.salary > e.salary
WHERE richer.id IS NULL
ORDER BY d.name;

The result is again Asha, Elena and Hiro.

What’s happening
  1. For each employee e, the LEFT JOIN looks for colleagues in the same department who earn more. Ben finds Asha; Dev finds Asha, Ben and Chloe.
  2. Asha finds nobody, so her row survives the outer join with richer.* all NULL.
  3. WHERE richer.id IS NULL keeps exactly those "no one richer" rows. Ties survive, because a colleague on the same salary isn't >.
  4. It works on any version and with any database, but it compares pairs within each group, so it gets slow when groups are large. With an index on (department_id, salary) it's fine for moderate sizes.

A fourth form is the correlated subquery WHERE e.salary = (SELECT MAX(e2.salary) FROM employees e2 WHERE e2.department_id = e.department_id), which also returned the same three rows and also keeps ties.

Learn more: Top N per group and Anti-joins and semi-joins.

Spot the bug: the lowest-paid employee per department

This query is meant to show the lowest-paid employee in each department. What does MySQL 9.7 do with it, and what would it have returned on a server without ONLY_FULL_GROUP_BY?

SQL
SELECT department_id, name, MIN(salary) AS lowest
FROM employees
WHERE department_id IS NOT NULL
GROUP BY department_id;
Show answer

MySQL 9.7 rejects it, because name isn't grouped and isn't determined by department_id:

Result
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'ch15.employees.name' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by

With the mode switched off (as on MySQL 5.6 and earlier, or a 5.7+ server configured loosely), it runs and returns wrong names:

SQL
SET SESSION sql_mode = REPLACE(@@sql_mode, 'ONLY_FULL_GROUP_BY', '');
SELECT department_id, name, MIN(salary) AS lowest
FROM employees WHERE department_id IS NOT NULL
GROUP BY department_id;
Result
+---------------+-------+-----------+
| department_id | name  | lowest    |
+---------------+-------+-----------+
|             1 | Asha  | 118000.00 |
|             2 | Elena |  95000.00 |
|             3 | Hiro  |  72000.00 |
+---------------+-------+-----------+
What’s happening
  1. MIN(salary) is computed correctly per group: 118,000, 95,000 and 72,000.
  2. name isn't aggregated, so with the check off MySQL picks a value from any row of the group — in practice usually the first row it reads, which here is the lowest id. Asha, Elena and Hiro are the highest earners, so every name is wrong.
  3. Nothing ties the non-aggregated name to the row that produced MIN(salary). That's the fundamental point: aggregates are computed per column, independently.
  4. ONLY_FULL_GROUP_BY has been on by default since MySQL 5.7.5 precisely to stop this. ANY_VALUE(name) silences the error but doesn't fix anything — it just says "I accept an arbitrary name".
  5. The fix is a greatest-n-per-group pattern. With a window function:
SQL
SELECT department_id, name, salary AS lowest
FROM (
  SELECT e.*, RANK() OVER (PARTITION BY department_id ORDER BY salary) AS rnk
  FROM employees e
  WHERE department_id IS NOT NULL
) AS r
WHERE rnk = 1;
Result
+---------------+-------+-----------+
| department_id | name  | lowest    |
+---------------+-------+-----------+
|             1 | Dev   | 118000.00 |
|             2 | Farid |  95000.00 |
|             3 | Isla  |  72000.00 |
+---------------+-------+-----------+
What’s happening
  1. RANK() … ORDER BY salary (ascending this time) gives the lowest earner in each department rank 1.
  2. Because the whole row is carried through the derived table, name now comes from the same row as the salary: Dev, Farid and Isla.
  3. If two people tied for lowest, RANK would keep both; ROW_NUMBER would keep one arbitrarily.

Learn more: ONLY_FULL_GROUP_BY and GROUP BY.

Top three salaries per department — RANK, DENSE_RANK or ROW_NUMBER?

"Return the employees who earn one of the top three salaries in their department." Predict the three ranking columns, then say which one answers the question.

SQL
WITH ranked AS (
  SELECT d.name AS department, e.name AS employee, e.salary,
         DENSE_RANK() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS dr,
         RANK()       OVER (PARTITION BY e.department_id ORDER BY e.salary DESC) AS rk,
         ROW_NUMBER() OVER (PARTITION BY e.department_id ORDER BY e.salary DESC, e.id) AS rn
  FROM employees e JOIN departments d ON d.id = e.department_id
)
SELECT * FROM ranked WHERE dr <= 3
ORDER BY department, salary DESC, employee;
Show answer

"Top three salaries" means three distinct salary values, so DENSE_RANK() <= 3 is the answer — and in Engineering it returns four people, because Ben and Chloe share second place.

Result
+-------------+----------+-----------+----+----+----+
| department  | employee | salary    | dr | rk | rn |
+-------------+----------+-----------+----+----+----+
| Engineering | Asha     | 185000.00 |  1 |  1 |  1 |
| Engineering | Ben      | 142000.00 |  2 |  2 |  2 |
| Engineering | Chloe    | 142000.00 |  2 |  2 |  3 |
| Engineering | Dev      | 118000.00 |  3 |  4 |  4 |
| Sales       | Elena    | 130000.00 |  1 |  1 |  1 |
| Sales       | Grace    |  99000.00 |  2 |  2 |  2 |
| Sales       | Farid    |  95000.00 |  3 |  3 |  3 |
| Support     | Hiro     |  88000.00 |  1 |  1 |  1 |
| Support     | Isla     |  72000.00 |  2 |  2 |  2 |
+-------------+----------+-----------+----+----+----+
What’s happening
  1. Every function restarts at 1 per department because of PARTITION BY e.department_id.
  2. In Engineering, dr goes 1, 2, 2, 3: Dev's 118,000 is the third distinct salary, so he's in. rk goes 1, 2, 2, 4, so rk <= 3 would drop Dev. rn goes 1, 2, 3, 4, so rn <= 3 would drop Dev too — and would have dropped Chloe instead of Dev if the tiebreaker had been different.
  3. Sales has no ties, so all three functions agree. Support only has two people, so it returns two rows: "top three" is an upper bound, not a promise.
  4. The e.id tiebreaker in the ROW_NUMBER makes its numbering deterministic; without it, which of Ben and Chloe is 2 can change between runs or versions.
  5. The interview point: say the tie policy out loud. "Top 3 people" → ROW_NUMBER. "Top 3 salaries" → DENSE_RANK. "Anyone who'd place in the top 3" (Olympic style) → RANK.

Before window functions: the same answer with a correlated count — "fewer than three distinct salaries in my department are higher than mine":

SQL
SELECT d.name AS department, e.name AS employee, e.salary
FROM employees e JOIN departments d ON d.id = e.department_id
WHERE (SELECT COUNT(DISTINCT e2.salary)
       FROM employees e2
       WHERE e2.department_id = e.department_id
         AND e2.salary > e.salary) < 3
ORDER BY department, e.salary DESC, employee;

It returned the same nine rows as the DENSE_RANK version.

What’s happening
  1. For Dev, the distinct higher salaries in Engineering are 185,000 and 142,000 — a count of 2, which is < 3, so he's in.
  2. For Asha the count is 0; for Ben and Chloe, 1. All in.
  3. This is LeetCode's "Department Top Three Salaries" reference solution. It's correct but runs the subquery once per employee; the window version sorts each department once.

Learn more: ROW_NUMBER, RANK and DENSE_RANK and Top N per group.

Each customer's most recent order

Show each customer's latest order (id, status, date). Customers who have never ordered should appear with NULLs.

orders (customer_id → id, status, ordered_at)
1 Rohit → 1 shipped 2025-02-01, 2 paid 2025-03-15
2 Maya  → 3 shipped 2025-02-10, 8 shipped 2025-04-05
3 Liam  → 4 cancelled 2025-02-14, 5 shipped 2025-03-01
4 Sofia → 6 pending 2025-03-20
5 Noah  → 7 paid 2025-04-02
6 Zara  → (no orders)
Show answer

A LEFT JOIN LATERAL reads most naturally: "for each customer, fetch their newest order".

SQL
SELECT c.name, o.id AS order_id, o.status, o.ordered_at
FROM customers c
LEFT JOIN LATERAL (
  SELECT id, status, ordered_at
  FROM orders
  WHERE customer_id = c.id
  ORDER BY ordered_at DESC
  LIMIT 1
) AS o ON TRUE
ORDER BY c.id;
Result
+-------+----------+---------+---------------------+
| name  | order_id | status  | ordered_at          |
+-------+----------+---------+---------------------+
| Rohit |        2 | paid    | 2025-03-15 12:30:00 |
| Maya  |        8 | shipped | 2025-04-05 15:40:00 |
| Liam  |        5 | shipped | 2025-03-01 07:45:00 |
| Sofia |        6 | pending | 2025-03-20 21:10:00 |
| Noah  |        7 | paid    | 2025-04-02 13:05:00 |
| Zara  |     NULL | NULL    | NULL                |
+-------+----------+---------+---------------------+
What’s happening
  1. LATERAL lets the derived table refer to c.id from the table to its left — without it, a derived table can't see outer columns. MySQL has supported it since 8.0.14.
  2. For each customer the subquery sorts that customer's orders newest first and keeps one. With an index on (customer_id, ordered_at) that's an index dive per customer, which is very fast when there are few customers and many orders.
  3. LEFT JOIN … ON TRUE keeps customers whose subquery returns nothing, so Zara appears with NULLs. A plain JOIN or comma would drop her.
  4. Changing LIMIT 1 to LIMIT 3 gives each customer's last three orders with no other change — the big advantage of this form.

Alternative 1: ROW_NUMBER():

SQL
SELECT c.name, o.id AS order_id, o.status, o.ordered_at
FROM (
  SELECT o.*, ROW_NUMBER() OVER (PARTITION BY customer_id
                                 ORDER BY ordered_at DESC, id DESC) AS rn
  FROM orders o
) AS o
JOIN customers c ON c.id = o.customer_id
WHERE o.rn = 1
ORDER BY c.id;

This returned the first five rows above — no Zara, because it starts from orders.

What’s happening
  1. The window numbers each customer's orders newest first; rn = 1 is the latest.
  2. id DESC is a tiebreaker in case two orders share a timestamp, so exactly one row per customer comes back.
  3. To include Zara, start from customers and LEFT JOIN the ranked orders with AND o.rn = 1 in the ON clause — putting it in WHERE would filter her out again.
  4. This form scans and numbers every order, which is the better plan when you need most of the table anyway.

Alternative 2: correlated MAX: WHERE o.ordered_at = (SELECT MAX(o2.ordered_at) FROM orders o2 WHERE o2.customer_id = o.customer_id) returned the same five rows. It works on any version, but if two orders share the maximum timestamp it returns both, so it isn't "exactly one per customer" unless the timestamp is unique.

Learn more: Derived tables and LEFT and RIGHT OUTER JOIN.

Employees who earn more than their department's average

List employees whose salary is above the average salary of their own department, with that average.

Show answer

Asha, Elena and Hiro. A window AVG puts each department's average on every row, so the comparison is a simple filter:

SQL
SELECT name, department_id, salary, ROUND(dept_avg, 2) AS dept_avg
FROM (
  SELECT e.*, AVG(salary) OVER (PARTITION BY department_id) AS dept_avg
  FROM employees e
  WHERE department_id IS NOT NULL
) AS t
WHERE salary > dept_avg
ORDER BY department_id, salary DESC;
Result
+-------+---------------+-----------+-----------+
| name  | department_id | salary    | dept_avg  |
+-------+---------------+-----------+-----------+
| Asha  |             1 | 185000.00 | 146750.00 |
| Elena |             2 | 130000.00 | 108000.00 |
| Hiro  |             3 |  88000.00 |  80000.00 |
+-------+---------------+-----------+-----------+
What’s happening
  1. AVG(salary) OVER (PARTITION BY department_id) has no ORDER BY, so the frame is the whole partition: every Engineering row gets (185000 + 142000 + 142000 + 118000) / 4 = 146750.
  2. The outer WHERE keeps rows above their own department's figure. Ben and Chloe (142,000) are below 146,750, so only Asha survives in Engineering.
  3. Sales averages 108,000 (Elena is above it); Support averages 80,000 (Hiro is above it).
  4. Note the ORDER BY pitfall: AVG(salary) OVER (PARTITION BY department_id ORDER BY salary) would be a running average, not the department average, because adding ORDER BY changes the default frame. See Window frames.

Alternative: correlated subquery (any version):

SQL
SELECT e.name, e.department_id, e.salary
FROM employees e
WHERE e.salary > (SELECT AVG(e2.salary) FROM employees e2
                  WHERE e2.department_id = e.department_id)
ORDER BY e.department_id, e.salary DESC;

It returned the same three people. Jonas drops out on his own here: his department_id is NULL, so e2.department_id = NULL matches nothing, the average is NULL, and salary > NULL is unknown.

What’s happening
  1. For each employee the subquery averages their department. Logically it runs once per outer row, so an index on department_id matters.
  2. It reads naturally and it's what you'd write on 5.7. You can't show the average next to each row without repeating the subquery in the SELECT list, which the window form gives you for free.
  3. A third form joins to a derived table of department_id, AVG(salary) grouped once — usually the most efficient on very old versions.

Learn more: Correlated subqueries and Window functions: OVER and PARTITION BY.

Customers who bought every book (relational division)

Which customers have bought every product in the Books category (ids 5 and 6), ignoring cancelled orders?

Book purchases (non-cancelled orders)
Rohit: order 2 → products 5 and 6
Liam:  order 5 → product 6
Noah:  order 7 → product 5 (quantity 3)
Show answer

Only Rohit. This is relational division — "find X related to all of Y" — and the standard trick is to count distinct matches per customer and compare with the size of the set:

SQL
SELECT c.name
FROM customers c
JOIN orders o       ON o.customer_id = c.id AND o.status <> 'cancelled'
JOIN order_items oi ON oi.order_id = o.id
JOIN products p     ON p.id = oi.product_id AND p.category_id = 4
GROUP BY c.id, c.name
HAVING COUNT(DISTINCT p.id) = (SELECT COUNT(*) FROM products WHERE category_id = 4);
Result
+-------+
| name  |
+-------+
| Rohit |
+-------+
What’s happening
  1. The joins produce one row per book line bought by each customer: Rohit has two (products 5 and 6), Liam and Noah one each.
  2. COUNT(DISTINCT p.id) counts different books per customer: Rohit 2, Liam 1, Noah 1.
  3. The subquery counts the books that exist: 2. Only Rohit's count equals it.
  4. DISTINCT matters: with COUNT(*), a customer who bought the same book in two separate orders would count 2 and wrongly pass. On this data COUNT(*) happens to give Rohit 2, Liam 1, Noah 1 — the bug is invisible until someone re-orders.

Alternative: double NOT EXISTS — "there is no book this customer hasn't bought":

SQL
SELECT c.name
FROM customers c
WHERE NOT EXISTS (
  SELECT 1 FROM products p
  WHERE p.category_id = 4
    AND NOT EXISTS (
      SELECT 1 FROM orders o JOIN order_items oi ON oi.order_id = o.id
      WHERE o.customer_id = c.id AND o.status <> 'cancelled'
        AND oi.product_id = p.id));

It also returned only Rohit.

What’s happening
  1. The innermost NOT EXISTS asks "has this customer not bought book p?".
  2. The middle NOT EXISTS asks "is there no such book?" — that is, every book has been bought.
  3. Edge case: if the category is empty, there's no book to be missing, so every customer qualifies. Run against a non-existent category 99, it returned all six customers. The COUNT version would return nobody (no rows to group). Mathematically "bought all of nothing" is true, but say which behaviour you want.
  4. The double negation is harder to read but is the textbook relational-algebra division, and it can stop early per customer.

Learn more: IN vs EXISTS and COUNT(*) vs COUNT(column) vs COUNT(DISTINCT).

#Running totals and comparisons with previous rows

Added

This family of problems asks a row to know about its neighbours: a running total that adds up everything so far, a moving average over the last few rows, the change since last month, or "was today warmer than yesterday?". Before MySQL 8.0 every one of these needed a self-join or a correlated subquery that re-read the table for each row. Window functions turned them into one pass: sort once, then let each row look backwards (LAG, a frame of preceding rows) or forwards (LEAD).

The mental model is a till receipt. The order column is the order the items were scanned; a running total is the subtotal printed after each line; LAG is "the line above me"; a frame like ROWS BETWEEN 2 PRECEDING AND CURRENT ROW is a little window you slide down the receipt. Two details catch people out: what "the previous row" means when rows are missing (yesterday's reading isn't always the previous row), and what the default frame does with ties in the ORDER BY column.

Most questions here use the order totals from the sample schema (cancelled order 4 left out):

SQL
SELECT o.id, o.customer_id, DATE(o.ordered_at) AS day,
       SUM(oi.quantity * oi.unit_price) AS total
FROM orders o JOIN order_items oi ON oi.order_id = o.id
WHERE o.status <> 'cancelled'
GROUP BY o.id
ORDER BY day;
Result
+----+-------------+------------+---------+
| id | customer_id | day        | total   |
+----+-------------+------------+---------+
|  1 |           1 | 2025-02-01 |  957.00 |
|  3 |           2 | 2025-02-10 | 1048.00 |
|  5 |           3 | 2025-03-01 |  127.00 |
|  2 |           1 | 2025-03-15 |   88.00 |
|  6 |           4 | 2025-03-20 |  249.00 |
|  7 |           5 | 2025-04-02 | 1016.00 |
|  8 |           2 | 2025-04-05 |  116.00 |
+----+-------------+------------+---------+
What’s happening
  1. The join gives one row per order line; SUM(quantity * unit_price) per o.id turns that into one total per order. Order 1 is a ThinkBook (899) plus two chargers (2 × 29) = 957.
  2. GROUP BY o.id is enough under ONLY_FULL_GROUP_BY even though customer_id and ordered_at aren't grouped: they're functionally dependent on the primary key o.id, and MySQL detects that.
  3. The WHERE removes order 4 (cancelled) before grouping, so it never contributes.
  4. These seven rows are the input for the first questions; the queries below wrap this in a CTE called totals.
Running total of revenue by order date

Add a running_total column to the order totals above, accumulating in date order.

Show answer

SUM() OVER (ORDER BY …) does it in one pass:

SQL
WITH totals AS (
  SELECT o.id, DATE(o.ordered_at) AS day, SUM(oi.quantity * oi.unit_price) AS total
  FROM orders o JOIN order_items oi ON oi.order_id = o.id
  WHERE o.status <> 'cancelled'
  GROUP BY o.id
)
SELECT id, day, total,
       SUM(total) OVER (ORDER BY day, id) AS running_total
FROM totals
ORDER BY day, id;
Result
+----+------------+---------+---------------+
| id | day        | total   | running_total |
+----+------------+---------+---------------+
|  1 | 2025-02-01 |  957.00 |        957.00 |
|  3 | 2025-02-10 | 1048.00 |       2005.00 |
|  5 | 2025-03-01 |  127.00 |       2132.00 |
|  2 | 2025-03-15 |   88.00 |       2220.00 |
|  6 | 2025-03-20 |  249.00 |       2469.00 |
|  7 | 2025-04-02 | 1016.00 |       3485.00 |
|  8 | 2025-04-05 |  116.00 |       3601.00 |
+----+------------+---------+---------------+
What’s happening
  1. OVER (ORDER BY day, id) sorts the seven rows by date. With an ORDER BY and no explicit frame, each row's frame is "from the first row up to me" (strictly, up to my last peer — see the next question).
  2. SUM(total) over that frame is the running total: 957, then 957 + 1048 = 2005, then 2005 + 127 = 2132, and so on up to 3601.
  3. The last value equals SUM(total) over the whole table, which is a quick sanity check.
  4. id is in the window's ORDER BY as a tiebreaker. No two orders here share a date, but if they did, id would stop them sharing a running total.
  5. Adding PARTITION BY customer_id gives a running total per customer instead: Rohit's two orders run 957 → 1045, Maya's 1048 → 1164.

Before MySQL 8.0, option 1: a correlated subquery that sums every row up to and including the current one:

SQL
SELECT t1.id, t1.day, t1.total,
       (SELECT SUM(t2.total) FROM totals t2
        WHERE t2.day < t1.day OR (t2.day = t1.day AND t2.id <= t1.id)) AS running_total
FROM totals t1
ORDER BY t1.day, t1.id;

It returned exactly the same seven running totals.

What’s happening
  1. For each row t1, the subquery adds up all rows that come before it in (day, id) order, plus itself.
  2. The OR (t2.day = t1.day AND t2.id <= t1.id) part is the tiebreaker written by hand.
  3. It's O(n²): row 7 re-reads seven rows, row 1,000 re-reads a thousand. Fine for a report over a few hundred rows, painful for a million. (A CTE in a 5.7 answer would be a derived table or a temporary table, since 5.7 has no WITH.)

Before MySQL 8.0, option 2: user variables. You'll still see this in old code:

SQL
SET @run := 0;
SELECT id, paid_on, amount, @run := @run + amount AS running
FROM payments ORDER BY paid_on, id;
SHOW WARNINGS;
Result
+---------+------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Level   | Code | Message                                                                                                                                                                                              |
+---------+------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Warning | 1287 | Setting user variables within expressions is deprecated and will be removed in a future release. Consider alternatives: 'SET variable=expression, ...', or 'SELECT expression(s) INTO variables(s)'. |
+---------+------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
What’s happening
  1. @run := @run + amount assigns as each row is produced, so it behaves like a running total — on the small payments table used in the next question it printed 100, 150, 175, 185, 225.
  2. It relies on MySQL evaluating the expression in the final sort order, which the manual has never guaranteed; with joins, GROUP BY or a different plan the order can change.
  3. Since MySQL 8.0.13, assigning a user variable inside an expression raises warning 1287, as shown, still present in 9.7. Treat it as legacy and replace it with a window function.

Learn more: LAG, LEAD and running totals.

Predict: a running total when two payments share a date

Predict the three running-total columns.

SQL
SELECT id, paid_on, amount,
       SUM(amount) OVER (ORDER BY paid_on)                          AS default_frame,
       SUM(amount) OVER (ORDER BY paid_on ROWS UNBOUNDED PRECEDING) AS rows_frame,
       SUM(amount) OVER (ORDER BY paid_on, id)                      AS tie_broken
FROM payments
ORDER BY paid_on, id;
payments
+----+------------+--------+
| id | paid_on    | amount |
+----+------------+--------+
|  1 | 2025-05-01 | 100.00 |
|  2 | 2025-05-02 |  50.00 |
|  3 | 2025-05-02 |  25.00 |
|  4 | 2025-05-03 |  10.00 |
|  5 | 2025-05-05 |  40.00 |
+----+------------+--------+
Show answer

Payments 2 and 3 both happened on 2 May. With the default frame, both show 175 — the total including both — while the ROWS frame and the tie-broken order give 150, then 175.

Result
+----+------------+--------+---------------+------------+------------+
| id | paid_on    | amount | default_frame | rows_frame | tie_broken |
+----+------------+--------+---------------+------------+------------+
|  1 | 2025-05-01 | 100.00 |        100.00 |     100.00 |     100.00 |
|  2 | 2025-05-02 |  50.00 |        175.00 |     150.00 |     150.00 |
|  3 | 2025-05-02 |  25.00 |        175.00 |     175.00 |     175.00 |
|  4 | 2025-05-03 |  10.00 |        185.00 |     185.00 |     185.00 |
|  5 | 2025-05-05 |  40.00 |        225.00 |     225.00 |     225.00 |
+----+------------+--------+---------------+------------+------------+
What’s happening
  1. With ORDER BY and no frame clause, the SQL standard default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE means "up to the current row and all its peers" — rows with the same paid_on.
  2. For payment 2, its peers are itself and payment 3, so default_frame adds 100 + 50 + 25 = 175. Payment 3 gets the same frame, so also 175. The running total "jumps" over the tied day.
  3. ROWS UNBOUNDED PRECEDING counts physical rows instead, so payment 2 sees only 100 + 50 = 150. But the window orders only by paid_on, so which of 2 and 3 is processed first is up to MySQL — here it was payment 2. The outer ORDER BY doesn't control that; on another plan the two values could swap.
  4. ORDER BY paid_on, id makes the order unique, so there are no peers and the default RANGE frame behaves like ROWS. That's the most robust fix: deterministic and readable.
  5. Which one is "right" depends on the report: a daily balance should show 175 for both 2 May rows (RANGE); a per-transaction statement should show 150 then 175.

Learn more: Window frames.

A 3-row moving average vs a 3-day moving average

Using the same payments, predict a moving average over "this and the previous two payments" and one over "this day and the previous two days".

SQL
SELECT id, paid_on, amount,
  ROUND(AVG(amount) OVER (ORDER BY paid_on, id
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS avg_last_3_rows,
  ROUND(AVG(amount) OVER (ORDER BY paid_on
        RANGE BETWEEN INTERVAL 2 DAY PRECEDING AND CURRENT ROW), 2) AS avg_last_3_days,
  COUNT(*) OVER (ORDER BY paid_on, id
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS n_rows
FROM payments
ORDER BY paid_on, id;
Show answer

The two columns differ on 2 May, 3 May and 5 May, because 3 rows and 3 days cover different sets of payments when days have two payments or none.

Result
+----+------------+--------+-----------------+-----------------+--------+
| id | paid_on    | amount | avg_last_3_rows | avg_last_3_days | n_rows |
+----+------------+--------+-----------------+-----------------+--------+
|  1 | 2025-05-01 | 100.00 |          100.00 |          100.00 |      1 |
|  2 | 2025-05-02 |  50.00 |           75.00 |           58.33 |      2 |
|  3 | 2025-05-02 |  25.00 |           58.33 |           58.33 |      3 |
|  4 | 2025-05-03 |  10.00 |           28.33 |           46.25 |      3 |
|  5 | 2025-05-05 |  40.00 |           25.00 |           25.00 |      3 |
+----+------------+--------+-----------------+-----------------+--------+
What’s happening
  1. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW is a sliding window of up to three physical rows. The first rows have fewer: n_rows shows 1, 2, then 3. Payment 2's average is (100 + 50) / 2 = 75 — an average of two, not three. Decide whether early rows should show a value or NULL (CASE WHEN n_rows = 3 …).
  2. RANGE BETWEEN INTERVAL 2 DAY PRECEDING AND CURRENT ROW is a window over values of paid_on: everything from two days before this row's date up to this date, including peers. For payment 2 (2 May) that's 1–2 May: (100 + 50 + 25) / 3 = 58.33.
  3. Payment 4 (3 May): the 3-row window is payments 2, 3, 4: (50 + 25 + 10) / 3 = 28.33. The 3-day window is 1–3 May, four payments: 185 / 4 = 46.25.
  4. Payment 5 (5 May): the 3-day window is 3–5 May, so it holds payments 4 and 5 only (nothing on 4 May): (10 + 40) / 2 = 25. The 3-row window happens to give (25 + 10 + 40) / 3 = 25 too.
  5. A RANGE frame with an INTERVAL needs a single date or number ORDER BY column, which is why it orders by paid_on alone. For "7-day rolling revenue" on calendar days, use RANGE with an interval; for "last 7 transactions", use ROWS.

Learn more: Window frames.

Month-over-month revenue change, and days since a customer's previous order
  1. Show monthly revenue with the previous month's revenue and the percentage change.
  2. For each order, how many days passed since that customer's previous order?
Show answer

For part 1, LAG reads the previous row's value in the window order:

SQL
WITH monthly AS (
  SELECT DATE_FORMAT(o.ordered_at, '%Y-%m') AS month,
         SUM(oi.quantity * oi.unit_price) AS revenue
  FROM orders o JOIN order_items oi ON oi.order_id = o.id
  WHERE o.status <> 'cancelled'
  GROUP BY month
)
SELECT month, revenue, prev,
       ROUND(100 * (revenue - prev) / prev, 1) AS pct_change
FROM (
  SELECT month, revenue, LAG(revenue) OVER w AS prev
  FROM monthly
  WINDOW w AS (ORDER BY month)
) AS t
ORDER BY month;
Result
+---------+---------+---------+------------+
| month   | revenue | prev    | pct_change |
+---------+---------+---------+------------+
| 2025-02 | 2005.00 |    NULL |       NULL |
| 2025-03 |  464.00 | 2005.00 |      -76.9 |
| 2025-04 | 1132.00 |  464.00 |      144.0 |
+---------+---------+---------+------------+
What’s happening
  1. The CTE totals revenue per month: February 957 + 1048 = 2005, March 127 + 88 + 249 = 464, April 1016 + 116 = 1132.
  2. LAG(revenue) OVER w returns the revenue of the row before in month order. February has no row before it, so prev is NULL; LAG(revenue, 1, 0) would give a default of 0 instead.
  3. WINDOW w AS (ORDER BY month) names the window once, so several functions can share it. The derived table means the percentage formula refers to prev rather than repeating LAG(…) three times.
  4. March: 100 × (464 − 2005) / 2005 = −76.9. April: 100 × (1132 − 464) / 464 = 144.0. February stays NULL because any arithmetic with NULL is NULL — usually what you want for "no previous month".
  5. '%Y-%m' strings sort correctly as text because they're zero-padded, year first.

Alternative for part 1: self-join on "the month before". The pre-8.0 form, which also makes a missing month explicit:

SQL
WITH monthly AS (
  SELECT DATE_FORMAT(o.ordered_at, '%Y-%m-01') AS month,
         SUM(oi.quantity * oi.unit_price) AS revenue
  FROM orders o JOIN order_items oi ON oi.order_id = o.id
  WHERE o.status <> 'cancelled'
  GROUP BY month
)
SELECT cur.month, cur.revenue, prev.revenue AS prev_month
FROM monthly cur
LEFT JOIN monthly prev ON prev.month = cur.month - INTERVAL 1 MONTH
ORDER BY cur.month;
Result
+------------+---------+------------+
| month      | revenue | prev_month |
+------------+---------+------------+
| 2025-02-01 | 2005.00 |       NULL |
| 2025-03-01 |  464.00 |    2005.00 |
| 2025-04-01 | 1132.00 |     464.00 |
+------------+---------+------------+
What’s happening
  1. The month key is now a first-of-month string like '2025-03-01', so cur.month - INTERVAL 1 MONTH is a real date arithmetic expression (MySQL converts the string to a date).
  2. The LEFT JOIN looks for the row whose month is exactly one calendar month earlier, and keeps February with NULL.
  3. The difference from LAG shows when a month has no sales: LAG would compare May with March (the previous row), while the self-join compares May with April and finds nothing. Which is right is a reporting decision — the next question has the same trap with days.

For part 2, partition the LAG by customer and subtract dates:

SQL
SELECT customer_id, id, DATE(ordered_at) AS day,
       DATEDIFF(ordered_at, LAG(ordered_at) OVER (PARTITION BY customer_id
                                                  ORDER BY ordered_at)) AS days_since_prev
FROM orders
ORDER BY customer_id, ordered_at;
Result
+-------------+----+------------+-----------------+
| customer_id | id | day        | days_since_prev |
+-------------+----+------------+-----------------+
|           1 |  1 | 2025-02-01 |            NULL |
|           1 |  2 | 2025-03-15 |              42 |
|           2 |  3 | 2025-02-10 |            NULL |
|           2 |  8 | 2025-04-05 |              54 |
|           3 |  4 | 2025-02-14 |            NULL |
|           3 |  5 | 2025-03-01 |              15 |
|           4 |  6 | 2025-03-20 |            NULL |
|           5 |  7 | 2025-04-02 |            NULL |
+-------------+----+------------+-----------------+
What’s happening
  1. PARTITION BY customer_id makes "previous" mean "this customer's previous order", so Maya's April order looks back to her February order, not to Noah's.
  2. The first order of each customer has no previous row: NULL.
  3. DATEDIFF ignores the time of day and counts calendar days: 1 Feb → 15 Mar is 42. Use TIMESTAMPDIFF(HOUR, …) when the time matters.
  4. Cancelled order 4 is included here on purpose — this is about order activity, not revenue.

Learn more: LAG, LEAD and running totals.

Days that were warmer than the day before (and the missing-day trap)

Return every date whose temperature was higher than the previous calendar day's. There's no reading for 4 July.

weather
+----+-------------+--------+
| id | recorded_on | temp_c |
+----+-------------+--------+
|  1 | 2025-07-01  |     21 |
|  2 | 2025-07-02  |     24 |
|  3 | 2025-07-03  |     22 |
|  4 | 2025-07-05  |     25 |
|  5 | 2025-07-06  |     23 |
|  6 | 2025-07-07  |     27 |
+----+-------------+--------+
Show answer

The answer is 2 July and 7 July. 5 July must not be returned: its previous calendar day (4 July) has no reading. A self-join on the date expresses "yesterday" exactly:

SQL
SELECT today.recorded_on, today.temp_c, yesterday.temp_c AS yesterday_temp
FROM weather today
JOIN weather yesterday
  ON yesterday.recorded_on = today.recorded_on - INTERVAL 1 DAY
WHERE today.temp_c > yesterday.temp_c;
Result
+-------------+--------+----------------+
| recorded_on | temp_c | yesterday_temp |
+-------------+--------+----------------+
| 2025-07-02  |     24 |             21 |
| 2025-07-07  |     27 |             23 |
+-------------+--------+----------------+
What’s happening
  1. For each row today, the join finds the row dated exactly one day earlier. 2 July pairs with 1 July; 3 July with 2 July.
  2. 5 July looks for 4 July, finds nothing, and the inner join drops it — correct, since there's nothing to compare with.
  3. The WHERE keeps pairs where today is warmer: 24 > 21 (2 July) and 27 > 23 (7 July). 3 July (22 < 24) and 6 July (23 < 25) fail.
  4. With an index on recorded_on (it's UNIQUE here), each lookup is a single index probe.

The tempting LAG version is subtly wrong:

SQL
SELECT recorded_on, temp_c
FROM (
  SELECT recorded_on, temp_c, LAG(temp_c) OVER (ORDER BY recorded_on) AS prev_temp
  FROM weather
) AS t
WHERE temp_c > prev_temp;
Result
+-------------+--------+
| recorded_on | temp_c |
+-------------+--------+
| 2025-07-02  |     24 |
| 2025-07-05  |     25 |
| 2025-07-07  |     27 |
+-------------+--------+
What’s happening
  1. LAG returns the previous row, not the previous day. For 5 July the previous row is 3 July (22), so 25 > 22 and it's returned.
  2. On data with no gaps, LAG and the self-join agree — which is why the bug survives testing.
  3. The fix keeps LAG but checks that the previous row really is yesterday: also take LAG(recorded_on) OVER w AS prev_day and add AND prev_day = recorded_on - INTERVAL 1 DAY. Run that way it returned 2 July and 7 July only.
  4. A third bug you'll see is joining on w1.id = w2.id + 1. It assumes ids are consecutive and in date order. Here ids have no gap, so it compared 5 July with 3 July and returned the same three rows as the plain LAG.

Learn more: LAG, LEAD and running totals and Self joins.

#Gaps and islands

Added

"Gaps and islands" is the name for any problem about runs in ordered data. Gaps are what's missing — invoice numbers that were skipped, days with no sales. Islands are unbroken stretches — consecutive login days, a server's up and down periods, a number repeated three times in a row. The problems look different but they're solved with two or three reusable tricks, which is why interviewers love them: if you know the tricks, they're five-line queries.

The mental model is beads on a string. Lay the values out in order. A gap is where the string shows between two beads (LEAD tells you how far it is to the next bead). An island is a run of touching beads, and the classic trick for labelling each run is to subtract the bead's position (ROW_NUMBER) from its value: inside a run both go up by one each step, so the difference stays constant, and it changes only when you cross a gap. That constant is a group key you can GROUP BY. When "touching" isn't arithmetic (a status that flips between up and down), the other trick is to flag every row where the value changes and take a running SUM of the flags.

The first two questions use this table of ticket numbers:

SQL
SELECT id,
       ROW_NUMBER() OVER (ORDER BY id) AS rn,
       id - ROW_NUMBER() OVER (ORDER BY id) AS grp
FROM tickets;
Result
+----+----+-----+
| id | rn | grp |
+----+----+-----+
|  1 |  1 |   0 |
|  2 |  2 |   0 |
|  3 |  3 |   0 |
|  5 |  4 |   1 |
|  6 |  5 |   1 |
|  9 |  6 |   3 |
| 10 |  7 |   3 |
| 11 |  8 |   3 |
| 15 |  9 |   6 |
+----+----+-----+
What’s happening
  1. The table holds ticket ids 1, 2, 3, 5, 6, 9, 10, 11, 15: tickets 4, 7, 8 and 12–14 are missing.
  2. ROW_NUMBER() OVER (ORDER BY id) is each id's position: 1, 2, 3, 4, ….
  3. Inside a run of consecutive ids, id and rn both increase by 1, so id - rn stays the same: 0 for 1–3, 1 for 5–6, 3 for 9–11, 6 for 15.
  4. Each time a gap is crossed, id jumps by more than 1 but rn still goes up by 1, so grp increases by the size of the gap. The value of grp doesn't mean anything on its own — it's only a label shared by one island.
  5. This only works because id is an integer that should go up by exactly one. For dates you subtract days instead (DATE_SUB(day, INTERVAL rn DAY)), as in the login-streak question.
Find the missing ticket numbers — as ranges and as a list
  1. Return each gap as a range (gap_start, gap_end).
  2. Return every missing id individually, up to the highest id.
Show answer

For part 1, compare each id with the next one using LEAD:

SQL
SELECT id + 1 AS gap_start, next_id - 1 AS gap_end
FROM (
  SELECT id, LEAD(id) OVER (ORDER BY id) AS next_id
  FROM tickets
) AS t
WHERE next_id - id > 1;
Result
+-----------+---------+
| gap_start | gap_end |
+-----------+---------+
|         4 |       4 |
|         7 |       8 |
|        12 |      14 |
+-----------+---------+
What’s happening
  1. LEAD(id) OVER (ORDER BY id) gives each row the next id: 1 → 2, 3 → 5, 6 → 9, 11 → 15, and 15 → NULL.
  2. Where the step is more than 1, there's a gap: 3 → 5 skips 4, 6 → 9 skips 7–8, 11 → 15 skips 12–14.
  3. The gap starts one after the current id and ends one before the next id.
  4. The last row has next_id = NULL, so NULL - 15 > 1 is unknown and it's filtered out — no special case needed.
  5. One sort, one pass: this is the form to use on MySQL 8.0+.

Alternative for part 1 (any version): a row starts a gap if id + 1 doesn't exist, and the gap ends just before the next existing id:

SQL
SELECT t.id + 1 AS gap_start,
       (SELECT MIN(t3.id) - 1 FROM tickets t3 WHERE t3.id > t.id) AS gap_end
FROM tickets t
WHERE NOT EXISTS (SELECT 1 FROM tickets t2 WHERE t2.id = t.id + 1)
  AND t.id < (SELECT MAX(id) FROM tickets);

It returned the same three ranges.

What’s happening
  1. NOT EXISTS (… t2.id = t.id + 1) keeps ids whose successor is missing: 3, 6, 11 and 15.
  2. t.id < MAX(id) removes 15 — after the highest id there's no gap, just the end of the data.
  3. The scalar subquery finds the next existing id (5, 9, 15) and subtracts one.
  4. With the primary key index, each probe is cheap, but there are two correlated lookups per row.

For part 2, generate every number from 1 to the maximum with a recursive CTE and keep the ones that don't exist:

SQL
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < (SELECT MAX(id) FROM tickets)
)
SELECT n AS missing_id
FROM seq LEFT JOIN tickets t ON t.id = seq.n
WHERE t.id IS NULL;
Result
+------------+
| missing_id |
+------------+
|          4 |
|          7 |
|          8 |
|         12 |
|         13 |
|         14 |
+------------+
What’s happening
  1. The anchor SELECT 1 AS n produces the first row; the recursive part adds n + 1 until n reaches 15. The CTE holds 1 … 15.
  2. The LEFT JOIN … WHERE t.id IS NULL is an anti-join: numbers with no matching ticket.
  3. This lists every missing id, which the range queries don't. It's also the pattern for a calendar of dates (next questions).
  4. Recursion is capped by cte_max_recursion_depth (default 1000), so a sequence of more than about a thousand numbers needs the limit raised — the calendar question shows the real error.

Learn more: Recursive CTEs and LAG, LEAD and running totals.

Turn the ticket ids into ranges (islands) — and why might it fail on other data?

Using the grp column from the introduction, return each run of consecutive ids with its start, end and size. Then explain why id - ROW_NUMBER() OVER (ORDER BY id) can error on a table whose ids start at 0 or below.

Show answer

Group by the difference:

SQL
SELECT MIN(id) AS range_start, MAX(id) AS range_end, COUNT(*) AS size
FROM (
  SELECT id, id - ROW_NUMBER() OVER (ORDER BY id) AS grp
  FROM tickets
) AS t
GROUP BY grp
ORDER BY range_start;
Result
+-------------+-----------+------+
| range_start | range_end | size |
+-------------+-----------+------+
|           1 |         3 |    3 |
|           5 |         6 |    2 |
|           9 |        11 |    3 |
|          15 |        15 |    1 |
+-------------+-----------+------+
What’s happening
  1. The derived table labels each id with its island key: 0, 0, 0, 1, 1, 3, 3, 3, 6.
  2. GROUP BY grp collapses each island; MIN and MAX are its ends and COUNT(*) its length.
  3. A single isolated id (15) is an island of size 1 — gaps and islands are two views of the same data.
  4. The ORDER BY range_start is needed: GROUP BY doesn't promise any output order in MySQL 8.0+.

The error: ROW_NUMBER() returns BIGINT UNSIGNED, and MySQL won't let an unsigned subtraction go below zero:

SQL
SELECT 0 - ROW_NUMBER() OVER () AS x;
Result
ERROR 1690 (22003): BIGINT UNSIGNED value is out of range in '(0 - row_number() OVER () )'
What’s happening
  1. CREATE TABLE … AS SELECT ROW_NUMBER() OVER () AS rn shows the type: bigint unsigned NOT NULL.
  2. When one operand of - is unsigned, MySQL does unsigned arithmetic, so 0 - 1 is out of range instead of -1.
  3. On the tickets data id >= rn always holds, so it never fires. With ids starting at 0, or when grouping by equal values inside a partition (the next question), the difference can go negative.
  4. The fix is to cast: CAST(id AS SIGNED) - CAST(ROW_NUMBER() OVER (…) AS SIGNED), or enable the NO_UNSIGNED_SUBTRACTION SQL mode. PostgreSQL's row_number() returns a signed bigint, so this is a MySQL-specific trap.

Learn more: Window functions: OVER and PARTITION BY and Numeric types: INT, DECIMAL and FLOAT.

Longest login streak per user

Find each user's streaks of consecutive login days, then each user's longest streak.

logins (user_id, login_on)
user 1: 2025-06-01, 06-02, 06-03, 06-05, 06-06
user 2: 2025-06-01, 06-03, 06-04, 06-05, 06-06
user 3: 2025-06-02
Show answer

User 1's longest streak is 3 days, user 2's 4 days, user 3's 1 day. Subtract the row number in days from each date to get a constant per streak:

SQL
WITH grouped AS (
  SELECT user_id, login_on,
         DATE_SUB(login_on, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id
                                                       ORDER BY login_on) DAY) AS grp
  FROM logins
)
SELECT user_id, MIN(login_on) AS streak_start, MAX(login_on) AS streak_end, COUNT(*) AS days
FROM grouped
GROUP BY user_id, grp
ORDER BY user_id, streak_start;
Result
+---------+--------------+------------+------+
| user_id | streak_start | streak_end | days |
+---------+--------------+------------+------+
|       1 | 2025-06-01   | 2025-06-03 |    3 |
|       1 | 2025-06-05   | 2025-06-06 |    2 |
|       2 | 2025-06-01   | 2025-06-01 |    1 |
|       2 | 2025-06-03   | 2025-06-06 |    4 |
|       3 | 2025-06-02   | 2025-06-02 |    1 |
+---------+--------------+------------+------+
What’s happening
  1. PARTITION BY user_id numbers each user's logins separately, in date order: user 1's are 1 … 5.
  2. Subtracting that many days: 1 June − 1 = 31 May, 2 June − 2 = 31 May, 3 June − 3 = 31 May, then 5 June − 4 = 1 June, 6 June − 5 = 1 June. Same date = same streak.
  3. GROUP BY user_id, grp gives one row per streak; COUNT(*) is its length. The grp date itself is meaningless — it's just a label.
  4. Date subtraction with DATE_SUB returns a DATE, so the unsigned-integer trap from the previous question doesn't apply here.
  5. The (user_id, login_on) primary key guarantees one row per user per day. If a user could log in twice on the same day, deduplicate first (SELECT DISTINCT user_id, DATE(logged_in_at)), or the second login would get its own row number and break the streak.

For the longest streak per user, aggregate once more:

SQL
WITH grouped AS (
  SELECT user_id, login_on,
         DATE_SUB(login_on, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id
                                                       ORDER BY login_on) DAY) AS grp
  FROM logins
), streaks AS (
  SELECT user_id, COUNT(*) AS days FROM grouped GROUP BY user_id, grp
)
SELECT user_id, MAX(days) AS longest_streak FROM streaks GROUP BY user_id;
Result
+---------+----------------+
| user_id | longest_streak |
+---------+----------------+
|       1 |              3 |
|       2 |              4 |
|       3 |              1 |
+---------+----------------+
What’s happening
  1. streaks is the previous result without the dates: one row per streak with its length.
  2. MAX(days) per user picks the longest.
  3. Chaining CTEs like this keeps each step testable on its own — a good habit to narrate in an interview.

Alternative: flag the starts and count them. A streak starts wherever the gap since the previous login isn't exactly one day; a running SUM of those flags numbers the streaks:

SQL
WITH flagged AS (
  SELECT user_id, login_on,
         CASE WHEN DATEDIFF(login_on, LAG(login_on) OVER (PARTITION BY user_id
                                                          ORDER BY login_on)) = 1
              THEN 0 ELSE 1 END AS new_streak
  FROM logins
), numbered AS (
  SELECT user_id, login_on,
         SUM(new_streak) OVER (PARTITION BY user_id ORDER BY login_on) AS streak_no
  FROM flagged
)
SELECT user_id, streak_no, MIN(login_on) AS streak_start, COUNT(*) AS days
FROM numbered
GROUP BY user_id, streak_no
ORDER BY user_id, streak_no;

It returned the same five streaks, numbered 1, 2 for users 1 and 2 and 1 for user 3.

What’s happening
  1. LAG(login_on) is the user's previous login. The first login has NULL, DATEDIFF is NULL, NULL = 1 is unknown, so CASE takes the ELSE branch: a new streak.
  2. A one-day difference continues the streak (0); anything else starts one (1).
  3. The running SUM turns the flags 1, 0, 0, 1, 0 into streak numbers 1, 1, 1, 2, 2.
  4. This form is more flexible than the subtraction trick: change = 1 to <= 2 and a one-day break no longer ends a streak, which the arithmetic trick can't express.

Learn more: LAG, LEAD and running totals.

Numbers that appear at least three times in a row

Find every num that appears in at least three consecutive rows (by id).

readings
+----+-----+
| id | num |
+----+-----+
|  1 |   1 |
|  2 |   1 |
|  3 |   1 |
|  4 |   2 |
|  5 |   1 |
|  6 |   2 |
|  7 |   2 |
|  8 |   2 |
|  9 |   2 |
| 10 |   3 |
+----+-----+
Show answer

1 and 2. The 1s at ids 1–3 are a run of three; the 2s at ids 6–9 a run of four. (The single 2 at id 4 and the 1 at id 5 don't count.) The classic answer is a three-way self-join:

SQL
SELECT DISTINCT r1.num AS consecutive_num
FROM readings r1
JOIN readings r2 ON r2.id = r1.id + 1 AND r2.num = r1.num
JOIN readings r3 ON r3.id = r1.id + 2 AND r3.num = r1.num;
Result
+-----------------+
| consecutive_num |
+-----------------+
|               1 |
|               2 |
+-----------------+
What’s happening
  1. Each r1 row looks for the next two rows by id and requires the same num.
  2. r1.id = 1 finds ids 2 and 3, both 1: a match. r1.id = 6 finds 7 and 8, all 2: a match; so does r1.id = 7 (8 and 9).
  3. The run of four 2s matches twice, so DISTINCT is needed to return 2 once.
  4. It assumes ids have no gaps. If id 7 had been deleted, ids 6 and 8 would be "consecutive" rows but r1.id + 1 wouldn't find anything.

Alternative 1: LAG twice — compare each row with the two before it in id order, which also tolerates gaps in the ids:

SQL
SELECT DISTINCT num AS consecutive_num
FROM (
  SELECT num,
         LAG(num, 1) OVER (ORDER BY id) AS prev1,
         LAG(num, 2) OVER (ORDER BY id) AS prev2
  FROM readings
) AS t
WHERE num = prev1 AND num = prev2;

It returned 1 and 2.

What’s happening
  1. LAG(num, 2) reaches two rows back. For id 3: num = 1, prev1 = 1, prev2 = 1 → match.
  2. For ids 8 and 9, the two rows before are both 2 → two matches, collapsed by DISTINCT.
  3. Changing "three" to "five" means adding more LAG columns — fine for a fixed small N, clumsy beyond that.

Alternative 2: islands of equal values — gives each run's length directly, so "at least N" is a HAVING:

SQL
SELECT num, MIN(id) AS from_id, MAX(id) AS to_id, COUNT(*) AS run_length
FROM (
  SELECT id, num,
         CAST(id AS SIGNED)
       - CAST(ROW_NUMBER() OVER (PARTITION BY num ORDER BY id) AS SIGNED) AS grp
  FROM readings
) AS t
GROUP BY num, grp
HAVING COUNT(*) >= 3;
Result
+-----+---------+-------+------------+
| num | from_id | to_id | run_length |
+-----+---------+-------+------------+
|   1 |       1 |     3 |          3 |
|   2 |       6 |     9 |          4 |
+-----+---------+-------+------------+
What’s happening
  1. Numbering within each value (PARTITION BY num): the 1s at ids 1, 2, 3, 5 get 1, 2, 3, 4; the 2s at ids 4, 6, 7, 8, 9 get 1, 2, 3, 4, 5.
  2. id - rn is constant within a run: the 1s give 0, 0, 0, 1 (ids 1–3 are one island, id 5 another); the 2s give 3, 4, 4, 4, 4.
  3. The CASTs are needed because this subtraction would otherwise be unsigned — here it doesn't go negative, but casting is the safe habit.
  4. HAVING COUNT(*) >= 3 keeps runs of three or more, and you get where each run starts and ends for free. This scales to any N, and it's still ids-must-be-gap-free; for gap-tolerant runs use ROW_NUMBER() OVER (ORDER BY id) in place of id.

Learn more: Self joins and ROW_NUMBER, RANK and DENSE_RANK.

Orders per day including the days with none — spot the bug

A report must list every day from 14 to 21 March 2025 with its number of orders, showing 0 on days without orders. This attempt returns 1 for every day. Why, and what's the fix?

SQL
WITH RECURSIVE calendar AS (
  SELECT DATE '2025-03-14' AS day
  UNION ALL
  SELECT day + INTERVAL 1 DAY FROM calendar WHERE day < '2025-03-21'
)
SELECT c.day, COUNT(*) AS orders
FROM calendar c
LEFT JOIN orders o ON DATE(o.ordered_at) = c.day
GROUP BY c.day
ORDER BY c.day;
Show answer

COUNT(*) counts rows, and a LEFT JOIN produces one row for a day with no match — the calendar row with NULLs on the order side. On this data each day has at most one order, so every day comes out as 1. Count a column from the right-hand table instead:

SQL
WITH RECURSIVE calendar AS (
  SELECT DATE '2025-03-14' AS day
  UNION ALL
  SELECT day + INTERVAL 1 DAY FROM calendar WHERE day < '2025-03-21'
)
SELECT c.day, COUNT(o.id) AS orders
FROM calendar c
LEFT JOIN orders o
  ON o.ordered_at >= c.day
 AND o.ordered_at <  c.day + INTERVAL 1 DAY
GROUP BY c.day
ORDER BY c.day;
Result
+------------+--------+
| day        | orders |
+------------+--------+
| 2025-03-14 |      0 |
| 2025-03-15 |      1 |
| 2025-03-16 |      0 |
| 2025-03-17 |      0 |
| 2025-03-18 |      0 |
| 2025-03-19 |      0 |
| 2025-03-20 |      1 |
| 2025-03-21 |      0 |
+------------+--------+
What’s happening
  1. The recursive CTE generates the eight dates. The anchor is a DATE literal, so the column is a DATE and day + INTERVAL 1 DAY stays a date.
  2. The LEFT JOIN keeps every calendar day. 15 March matches order 2 and 20 March matches order 6; the other six days get one row with o.* all NULL.
  3. COUNT(o.id) ignores NULLs, so empty days count 0. COUNT(*) counted the NULL-padded row as 1 — that was the bug.
  4. The join condition is also rewritten as a half-open range on ordered_at instead of DATE(o.ordered_at) = c.day. Both give the same answer, but wrapping the column in DATE() stops MySQL using an index on ordered_at; the range form can.
  5. This "generate the calendar, then LEFT JOIN the facts" shape is how every gap-free report is built. Many schemas keep a permanent calendar table for it.

The recursion limit. Generating three years of days hits MySQL's default cap of 1,000 recursion steps:

SQL
WITH RECURSIVE calendar AS (
  SELECT DATE '2025-01-01' AS day
  UNION ALL
  SELECT day + INTERVAL 1 DAY FROM calendar WHERE day < '2027-12-31'
)
SELECT COUNT(*) FROM calendar;
Result
ERROR 3636 (HY000): Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.
What’s happening
  1. 2025-01-01 to 2027-12-31 is 1,095 days, which needs more than 1,000 recursive steps.
  2. cte_max_recursion_depth (default 1000) is a safety net against runaway recursion, such as a missing or wrong stop condition.
  3. After SET SESSION cte_max_recursion_depth = 2000; the same query returned 1095.
  4. For long ranges, a permanent calendar table (or a cross join of digit tables) avoids recursion entirely.

Learn more: Recursive CTEs and COUNT(*) vs COUNT(column) vs COUNT(DISTINCT).

Group server checks into up and down periods

Collapse these checks into periods: each period is a run of the same status, with its first and last check time.

server_checks
+---------------------+--------+
| checked_at          | status |
+---------------------+--------+
| 2025-08-01 10:00:00 | up     |
| 2025-08-01 10:05:00 | up     |
| 2025-08-01 10:10:00 | down   |
| 2025-08-01 10:15:00 | down   |
| 2025-08-01 10:20:00 | up     |
| 2025-08-01 10:25:00 | down   |
| 2025-08-01 10:30:00 | up     |
| 2025-08-01 10:35:00 | up     |
+---------------------+--------+
Show answer

There are five periods: up, down, up, down, up. A plain GROUP BY status would wrongly merge them into two rows. Flag every change and take a running sum to number the periods:

SQL
WITH flagged AS (
  SELECT checked_at, status,
         CASE WHEN status = LAG(status) OVER (ORDER BY checked_at) THEN 0 ELSE 1 END AS changed
  FROM server_checks
), periods AS (
  SELECT checked_at, status,
         SUM(changed) OVER (ORDER BY checked_at) AS period_no
  FROM flagged
)
SELECT period_no, status, MIN(checked_at) AS first_check,
       MAX(checked_at) AS last_check, COUNT(*) AS checks
FROM periods
GROUP BY period_no, status
ORDER BY period_no;
Result
+-----------+--------+---------------------+---------------------+--------+
| period_no | status | first_check         | last_check          | checks |
+-----------+--------+---------------------+---------------------+--------+
|         1 | up     | 2025-08-01 10:00:00 | 2025-08-01 10:05:00 |      2 |
|         2 | down   | 2025-08-01 10:10:00 | 2025-08-01 10:15:00 |      2 |
|         3 | up     | 2025-08-01 10:20:00 | 2025-08-01 10:20:00 |      1 |
|         4 | down   | 2025-08-01 10:25:00 | 2025-08-01 10:25:00 |      1 |
|         5 | up     | 2025-08-01 10:30:00 | 2025-08-01 10:35:00 |      2 |
+-----------+--------+---------------------+---------------------+--------+
What’s happening
  1. LAG(status) is the previous check's status. The CASE produces changed = 1 where the status differs from the previous one, and also on the first row, where LAG is NULL. The flags are 1, 0, 1, 0, 1, 1, 1, 0.
  2. The running SUM(changed) OVER (ORDER BY checked_at) turns those into period numbers 1, 1, 2, 2, 3, 4, 5, 5. Every change increments the counter; repeats leave it alone.
  3. GROUP BY period_no, status collapses each period. status is in the GROUP BY only so it can be shown — it's constant within a period.
  4. The arithmetic trick (value minus row number) can't do this, because statuses aren't numbers that go up by one. The "flag and sum" trick works for any value you can compare.
  5. To get durations, use LEAD(first_check) OVER (ORDER BY period_no) as each period's end — a period lasts until the next one begins, not until its own last check.

Learn more: LAG, LEAD and running totals and CASE expressions.

#Hierarchies and self-referencing tables

Added

A self-referencing table stores a tree inside one table: each row points at its parent through a column like manager_id or parent_id. The site's employees table is an org chart (Asha manages Ben, who manages Dev), and categories is a category tree (Laptops sits under Electronics). This "adjacency list" design is the simplest way to store a hierarchy, and it produces two kinds of interview question: one level problems (compare an employee with their manager) solved with a self-join, and any depth problems (everyone under Asha, a category and all its subcategories) solved with a recursive CTE.

The mental model for a self-join is two copies of the same staff list on the desk: you read an employee from the left copy and look up their manager in the right copy. For a recursive CTE, think of ripples: the anchor query drops the stone (the root rows), and each recursive step adds the next ring out (the children of the previous ring), until a step adds nothing.

Here's the org chart these questions use, built with the recursive CTE from the third question:

SQL
WITH RECURSIVE org AS (
  SELECT id, name, 0 AS depth, CAST(name AS CHAR(200)) AS path
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.id, e.name, org.depth + 1, CONCAT(org.path, ' > ', e.name)
  FROM employees e JOIN org ON e.manager_id = org.id
)
SELECT CONCAT(REPEAT('  ', depth), name) AS org_chart, depth, path
FROM org ORDER BY path;
Result
+-----------+-------+----------------------+
| org_chart | depth | path                 |
+-----------+-------+----------------------+
| Asha      |     0 | Asha                 |
|   Ben     |     1 | Asha > Ben           |
|     Dev   |     2 | Asha > Ben > Dev     |
|   Chloe   |     1 | Asha > Chloe         |
|   Elena   |     1 | Asha > Elena         |
|     Farid |     2 | Asha > Elena > Farid |
|     Grace |     2 | Asha > Elena > Grace |
|   Hiro    |     1 | Asha > Hiro          |
|     Isla  |     2 | Asha > Hiro > Isla   |
|   Jonas   |     1 | Asha > Jonas         |
+-----------+-------+----------------------+
What’s happening
  1. The anchor (WHERE manager_id IS NULL) finds the root: Asha, at depth 0, with a path of just her name.
  2. The first recursive step joins employees to the rows found so far: everyone whose manager_id is Asha's id — Ben, Chloe, Elena, Hiro and Jonas — at depth 1.
  3. The next step finds the children of those five: Dev (under Ben), Farid and Grace (under Elena), Isla (under Hiro), at depth 2. The step after finds no one, so recursion stops.
  4. path accumulates names with CONCAT, and sorting by it puts each person directly under their manager — an indented org chart in one query.
  5. CAST(name AS CHAR(200)) in the anchor is not decoration: the anchor decides the column's type, and without room to grow the path would fail. The third question shows the real error.
Employees who earn more than their manager

Return the names of employees who earn more than their direct manager.

staff
+----+-------+--------+------------+
| id | name  | salary | manager_id |
+----+-------+--------+------------+
|  1 | Joe   |  70000 |          3 |
|  2 | Henry |  80000 |          4 |
|  3 | Sam   |  60000 |       NULL |
|  4 | Max   |  90000 |       NULL |
|  5 | Ana   |  65000 |          3 |
+----+-------+--------+------------+
Show answer

Joe and Ana — both report to Sam, who earns 60,000. Join the table to itself, once as the employee and once as the manager:

SQL
SELECT e.name AS employee
FROM staff e
JOIN staff m ON m.id = e.manager_id
WHERE e.salary > m.salary;
Result
+----------+
| employee |
+----------+
| Joe      |
| Ana      |
+----------+
What’s happening
  1. staff e is the employee copy and staff m the manager copy. The aliases are mandatory: without them MySQL can't tell which salary you mean.
  2. ON m.id = e.manager_id pairs each employee with their manager's row: Joe–Sam, Henry–Max, Ana–Sam.
  3. Sam and Max have no manager. Their manager_id is NULL, which matches no id, so the inner join drops them — correct, since they can't out-earn a manager they don't have.
  4. The WHERE compares the two copies' salaries: Joe 70,000 > Sam 60,000 and Ana 65,000 > 60,000 pass; Henry 80,000 < Max 90,000 doesn't.

Alternative: correlated subquery:

SQL
SELECT e.name AS employee
FROM staff e
WHERE e.salary > (SELECT m.salary FROM staff m WHERE m.id = e.manager_id);

It returned Joe and Ana too.

What’s happening
  1. For each employee, the subquery fetches one value: the manager's salary.
  2. For Sam and Max it finds no row, so the scalar subquery is NULL, salary > NULL is unknown, and they're filtered out — the same result as the inner join, for a different reason.
  3. The join is the usual answer because it can also show the manager's name and salary in the output; the subquery can only test.

On the site's own employees table the same self-join returns Empty set: nobody out-earns their manager (Dev's 118,000 is below Ben's 142,000; Grace's 99,000 is below Elena's 130,000). Always check the empty case is really empty — a wrong join condition also returns nothing.

Learn more: Self joins.

Managers with at least two direct reports — and everyone with their manager's name
  1. List every employee with their manager's name, showing (no manager) for the top of the tree.
  2. List the managers who have at least two direct reports, with the count.
Show answer

For part 1, a LEFT self-join keeps the employee who has no manager:

SQL
SELECT e.name AS employee, COALESCE(m.name, '(no manager)') AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id
ORDER BY e.id;
Result
+----------+--------------+
| employee | manager      |
+----------+--------------+
| Asha     | (no manager) |
| Ben      | Asha         |
| Chloe    | Asha         |
| Dev      | Ben          |
| Elena    | Asha         |
| Farid    | Elena        |
| Grace    | Elena        |
| Hiro     | Asha         |
| Isla     | Hiro         |
| Jonas    | Asha         |
+----------+--------------+
What’s happening
  1. Every employee is kept because they're on the left of a LEFT JOIN.
  2. Asha's manager_id is NULL, so no manager row matches and m.name is NULL. With an inner join she'd vanish — the classic bug in this question.
  3. COALESCE(m.name, '(no manager)') replaces that NULL with a label for display.

For part 2, join employees to their managers and group by the manager:

SQL
SELECT m.name AS manager, COUNT(*) AS direct_reports
FROM employees e
JOIN employees m ON m.id = e.manager_id
GROUP BY m.id, m.name
HAVING COUNT(*) >= 2
ORDER BY direct_reports DESC;
Result
+---------+----------------+
| manager | direct_reports |
+---------+----------------+
| Asha    |              5 |
| Elena   |              2 |
+---------+----------------+
What’s happening
  1. The join produces one row per (employee, manager) pair: nine rows, because Asha has no manager.
  2. Grouping by the manager counts their direct reports: Asha 5 (Ben, Chloe, Elena, Hiro, Jonas), Elena 2, Ben 1, Hiro 1.
  3. HAVING COUNT(*) >= 2 keeps Asha and Elena.
  4. Grouping by m.id as well as m.name matters if two managers could share a name; MySQL accepts m.name in the SELECT because it's functionally dependent on m.id.

The shorter version without a join is SELECT name FROM employees WHERE id IN (SELECT manager_id FROM employees GROUP BY manager_id HAVING COUNT(*) >= 2), which returned Asha and Elena — but it can't show the count. (Here IN is safe even though manager_id has a NULL: the NULL group has only one row, so it's removed by HAVING, and IN with a NULL only causes trouble under NOT IN.)

Learn more: Self joins and HAVING vs WHERE.

Spot the bug: the recursive path that fails with "Data too long"

This recursive CTE labels the root 'CEO' and appends names. It fails. Why?

SQL
WITH RECURSIVE org AS (
  SELECT id, 'CEO' AS title_path FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.id, CONCAT(org.title_path, ' > ', e.name)
  FROM employees e JOIN org ON e.manager_id = org.id
)
SELECT * FROM org;
Show answer

The anchor fixes the column types. 'CEO' is a three-character string, so title_path becomes a column that holds three characters, and the first recursive row ('CEO > Ben') doesn't fit:

Result
ERROR 1406 (22001): Data too long for column 'title_path' at row 1
What’s happening
  1. MySQL works out a recursive CTE's column types from the anchor (non-recursive) part only. A literal 'CEO' gives VARCHAR(3).
  2. The recursive part produces CONCAT('CEO', ' > ', 'Ben'), nine characters, for a column that holds three.
  3. In strict SQL mode (the default) that's an error. With strict mode off it's worse: run with sql_mode = '', the query succeeded and every row's title_path was just CEO — truncated, and SHOW WARNINGS was empty, so nothing told us.
  4. The fix is to give the anchor room: CAST('CEO' AS CHAR(500)) AS title_path. That's why the org-chart query at the top of this topic casts name to CHAR(200).
  5. It "worked" when the anchor was name because name is VARCHAR(50) and these paths are under 50 characters. It would break on a deeper tree — a time bomb, so cast anyway.

The same rule explains a quieter bug: an integer counter like 0 AS depth gets a small integer type, which is fine for depth but not for, say, a running product that grows quickly. Cast in the anchor whenever the recursive part can grow a value.

Learn more: Recursive CTEs.

Isla's chain of command, and the total team under each manager
  1. List Isla's management chain up to the top, nearest manager first.
  2. For every manager, count all the people below them (direct and indirect) and their total salary.
Show answer

For part 1, walk upwards: the anchor is Isla, and each step joins to the previous row's manager:

SQL
WITH RECURSIVE chain AS (
  SELECT id, name, manager_id, 0 AS level
  FROM employees WHERE name = 'Isla'
  UNION ALL
  SELECT m.id, m.name, m.manager_id, chain.level + 1
  FROM employees m JOIN chain ON m.id = chain.manager_id
)
SELECT level, name FROM chain ORDER BY level;
Result
+-------+------+
| level | name |
+-------+------+
|     0 | Isla |
|     1 | Hiro |
|     2 | Asha |
+-------+------+
What’s happening
  1. The anchor selects Isla at level 0. Her manager_id is 8 (Hiro).
  2. The recursive step joins on m.id = chain.manager_id — the reverse direction of the org chart — and finds Hiro at level 1. Hiro's manager is Asha, found at level 2.
  3. Asha's manager_id is NULL, so the next step matches nothing and recursion stops.
  4. Going up is always a single chain (each person has one manager), so it's cheap; going down fans out.

For part 2, generate every (manager, report) pair at any depth, then aggregate:

SQL
WITH RECURSIVE reports AS (
  SELECT manager_id AS boss_id, id AS report_id
  FROM employees WHERE manager_id IS NOT NULL
  UNION ALL
  SELECT r.boss_id, e.id
  FROM reports r JOIN employees e ON e.manager_id = r.report_id
)
SELECT b.name AS manager, COUNT(*) AS all_reports, SUM(e.salary) AS team_salary
FROM reports r
JOIN employees b ON b.id = r.boss_id
JOIN employees e ON e.id = r.report_id
GROUP BY b.id, b.name
ORDER BY all_reports DESC, manager;
Result
+---------+-------------+-------------+
| manager | all_reports | team_salary |
+---------+-------------+-------------+
| Asha    |           9 |   977000.00 |
| Elena   |           2 |   194000.00 |
| Ben     |           1 |   118000.00 |
| Hiro    |           1 |    72000.00 |
+---------+-------------+-------------+
What’s happening
  1. The anchor produces every direct pair: (Asha, Ben), (Asha, Chloe), (Ben, Dev), (Elena, Farid)… nine pairs.
  2. The recursive step keeps the boss and moves the report one level down: (Asha, Ben) + Ben's report Dev → (Asha, Dev). That's how Asha collects her indirect reports.
  3. After two levels nothing new appears. Asha ends with 9 pairs — everyone except herself.
  4. The joins look up names and salaries; SUM(e.salary) is the cost of each manager's whole team (not including the manager): Asha's is the other nine salaries, 977000.
  5. This is a transitive closure. Some schemas store it permanently as a "closure table" (ancestor_id, descendant_id, depth) so these questions become simple joins without recursion.

Learn more: Recursive CTEs.

All products under Electronics, at any depth

"List every product in the Electronics category." Why does the obvious join return nothing, and what's the correct query?

categories
+----+-------------+-----------+
| id | name        | parent_id |
+----+-------------+-----------+
|  1 | Electronics |      NULL |
|  2 | Laptops     |         1 |
|  3 | Phones      |         1 |
|  4 | Books       |      NULL |
|  5 | Accessories |         1 |
+----+-------------+-----------+
Show answer

No product is assigned to Electronics directly — they're all in its subcategories — so SELECT p.name FROM products p JOIN categories c ON c.id = p.category_id WHERE c.name = 'Electronics' returns Empty set. Collect the category subtree first, then join products to it:

SQL
WITH RECURSIVE tree AS (
  SELECT id, name FROM categories WHERE name = 'Electronics'
  UNION ALL
  SELECT c.id, c.name FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT t.name AS category, p.name AS product, p.price
FROM tree t
JOIN products p ON p.category_id = t.id
ORDER BY t.name, p.price DESC;
Result
+-------------+-----------------------------+---------+
| category    | product                     | price   |
+-------------+-----------------------------+---------+
| Accessories | Noise-cancelling Headphones |  249.00 |
| Accessories | USB-C Charger               |   29.00 |
| Laptops     | MacBook Air                 | 1299.00 |
| Laptops     | ThinkBook 14                |  899.00 |
| Phones      | iPhone 17                   | 1099.00 |
| Phones      | Pixel 10                    |  799.00 |
+-------------+-----------------------------+---------+
What’s happening
  1. The anchor finds Electronics (id 1).
  2. The recursive step finds its children: Laptops, Phones and Accessories. None of them has children, so the next step is empty and the CTE holds four categories.
  3. Joining products to all four returns the six Electronics products, including the iPhone with zero stock — the query asks about category, not availability.
  4. If a "Gaming laptops" category were later added under Laptops, this query would include it with no change. A non-recursive WHERE c.id = 1 OR c.parent_id = 1 would miss it — that's the point of recursion.

Learn more: Recursive CTEs and Joining more than two tables.

A cycle in the tree — what happens, and how do you guard against it?

Someone sets B's parent to D, creating the loop B → C → D → B. Predict what this recursive walk from B does, and write a version that stops.

SQL
WITH RECURSIVE walk AS (
  SELECT id, name, parent_id FROM links WHERE id = 2
  UNION ALL
  SELECT l.id, l.name, l.parent_id
  FROM links l JOIN walk w ON l.parent_id = w.id
)
SELECT COUNT(*) FROM walk;
links (after the bad update)
+----+------+-----------+
| id | name | parent_id |
+----+------+-----------+
|  1 | A    |      NULL |
|  2 | B    |         4 |
|  3 | C    |         2 |
|  4 | D    |         3 |
+----+------+-----------+
Show answer

It never finishes on its own: B's child is C, C's child is D, D's child is B again, forever. MySQL stops it at the recursion limit:

Result
ERROR 3636 (HY000): Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.

Carry the ids you've already visited and stop when you meet one again:

SQL
WITH RECURSIVE walk AS (
  SELECT id, name, CAST(id AS CHAR(500)) AS seen, 0 AS is_cycle
  FROM links WHERE id = 2
  UNION ALL
  SELECT l.id, l.name, CONCAT(w.seen, ',', l.id), FIND_IN_SET(l.id, w.seen) > 0
  FROM links l JOIN walk w ON l.parent_id = w.id
  WHERE w.is_cycle = 0
)
SELECT id, name, seen, is_cycle FROM walk;
Result
+------+------+---------+----------+
| id   | name | seen    | is_cycle |
+------+------+---------+----------+
|    2 | B    | 2       |        0 |
|    3 | C    | 2,3     |        0 |
|    4 | D    | 2,3,4   |        0 |
|    2 | B    | 2,3,4,2 |        1 |
+------+------+---------+----------+
What’s happening
  1. seen is a comma-separated list of the ids on the current path, cast wide in the anchor so it can grow.
  2. Each step checks whether the new id is already in the list with FIND_IN_SET(l.id, w.seen), which returns its position or 0.
  3. When the walk reaches B again, is_cycle becomes 1 and the row is still produced, so you can see where the loop closes (2,3,4,2).
  4. WHERE w.is_cycle = 0 stops the recursion from extending a row that has already looped, so the walk ends after four rows.
  5. Raising cte_max_recursion_depth would not help — a cycle is infinite. The real fix is in the data: a trigger or application check that refuses to make a node its own ancestor.

Alternative: UNION DISTINCT. Replacing UNION ALL with UNION DISTINCT (with only id, name in the CTE) also terminates, returning B, C and D once each: when the step produces (2, 'B') again, it's a duplicate of a row already in the result, so it's discarded and the recursion runs out of new rows. It's simpler, but it only works when the rows you carry don't change from step to step — add a depth column and every row is "new" again: with w.depth + 1 in the CTE, the UNION DISTINCT walk failed with the same error 3636.

Learn more: Recursive CTEs and Triggers.

#Pivoting and reporting

Added

Reporting questions ask you to reshape data for humans: months across the top instead of down the side (a pivot), quarterly columns turned back into rows (an unpivot), subtotals and a grand total, each row's share of the whole. SQL Server and Oracle have a PIVOT keyword; MySQL doesn't, so the MySQL answer is always conditional aggregation — SUM(CASE WHEN … THEN value END) once per output column — which also happens to be the portable answer everywhere.

The mental model is a sorting machine with labelled bins. GROUP BY customer gives each customer a row; each SUM(CASE WHEN month = 'Feb' …) is a bin on that row that only accepts February's values and ignores everything else. You decide the bins in advance — they're columns, and SQL needs to know the columns before it runs the query. When the bins aren't known in advance (one column per status that exists today), you have to build the SQL text first and then run it: a "dynamic pivot".

Most questions here start from order revenue per customer per month:

SQL
SELECT c.name AS customer, DATE_FORMAT(o.ordered_at, '%Y-%m') AS month,
       SUM(oi.quantity * oi.unit_price) AS revenue
FROM customers c
JOIN orders o       ON o.customer_id = c.id AND o.status <> 'cancelled'
JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.name, month
ORDER BY c.id, month;
Result
+----------+---------+---------+
| customer | month   | revenue |
+----------+---------+---------+
| Rohit    | 2025-02 |  957.00 |
| Rohit    | 2025-03 |   88.00 |
| Maya     | 2025-02 | 1048.00 |
| Maya     | 2025-04 |  116.00 |
| Liam     | 2025-03 |  127.00 |
| Sofia    | 2025-03 |  249.00 |
| Noah     | 2025-04 | 1016.00 |
+----------+---------+---------+
What’s happening
  1. The three-table join produces one row per order line, with the cancelled order filtered out in the ON clause of the first join.
  2. GROUP BY c.id, c.name, month gives one row per customer per month that had sales. Grouping by the alias month is a MySQL extension that the standard doesn't allow, but it's widely used.
  3. This is the "long" (or "tall") shape: one row per fact. Zara isn't here (no orders), and months with no sales for a customer simply have no row.
  4. A pivot turns this into one row per customer with a column per month, putting 0 (or NULL) where a row is missing.
Pivot revenue into one column per month

Turn the revenue above into one row per customer with feb, mar and apr columns and a total, showing 0 where a customer had no sales that month.

Show answer

One SUM(CASE …) per month column:

SQL
SELECT c.name AS customer,
  SUM(CASE WHEN o.ordered_at >= '2025-02-01' AND o.ordered_at < '2025-03-01'
           THEN oi.quantity * oi.unit_price ELSE 0 END) AS feb,
  SUM(CASE WHEN o.ordered_at >= '2025-03-01' AND o.ordered_at < '2025-04-01'
           THEN oi.quantity * oi.unit_price ELSE 0 END) AS mar,
  SUM(CASE WHEN o.ordered_at >= '2025-04-01' AND o.ordered_at < '2025-05-01'
           THEN oi.quantity * oi.unit_price ELSE 0 END) AS apr,
  SUM(oi.quantity * oi.unit_price) AS total
FROM customers c
JOIN orders o       ON o.customer_id = c.id AND o.status <> 'cancelled'
JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;
Result
+----------+---------+--------+---------+---------+
| customer | feb     | mar    | apr     | total   |
+----------+---------+--------+---------+---------+
| Rohit    |  957.00 |  88.00 |    0.00 | 1045.00 |
| Maya     | 1048.00 |   0.00 |  116.00 | 1164.00 |
| Liam     |    0.00 | 127.00 |    0.00 |  127.00 |
| Sofia    |    0.00 | 249.00 |    0.00 |  249.00 |
| Noah     |    0.00 |   0.00 | 1016.00 | 1016.00 |
+----------+---------+--------+---------+---------+
What’s happening
  1. GROUP BY c.id, c.name makes one row per customer — the pivot's row key. All of a customer's order lines fall into that group.
  2. Each CASE passes a line's value through only if it belongs to that month, and 0 otherwise, so feb sums February lines and nothing else. Rohit's February: 899 + 2 × 29 = 957.
  3. ELSE 0 is why empty cells show 0.00. Leave the ELSE out and CASE returns NULL for non-matching lines; a bin that receives only NULLs sums to NULL.
  4. The date ranges are half-open (>= start AND < next start), which is correct for DATETIME values at any time of day and can use an index — MONTH(o.ordered_at) = 2 would also match February of every year.
  5. The columns are fixed in the SQL text: adding May means editing the query. That's the essence of a static pivot.

MySQL shortcuts: booleans are integers (TRUE is 1), and IF() is a two-branch CASE. This version shows both, and the difference NULL makes:

SQL
SELECT c.name AS customer,
  SUM((MONTH(o.ordered_at) = 2) * oi.quantity * oi.unit_price) AS feb,
  SUM(IF(MONTH(o.ordered_at) = 3, oi.quantity * oi.unit_price, NULL)) AS mar
FROM customers c
JOIN orders o       ON o.customer_id = c.id AND o.status <> 'cancelled'
JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.name
ORDER BY c.id;
Result
+----------+---------+--------+
| customer | feb     | mar    |
+----------+---------+--------+
| Rohit    |  957.00 |  88.00 |
| Maya     | 1048.00 |   NULL |
| Liam     |    0.00 | 127.00 |
| Sofia    |    0.00 | 249.00 |
| Noah     |    0.00 |   NULL |
+----------+---------+--------+
What’s happening
  1. (MONTH(o.ordered_at) = 2) is 1 or 0, so multiplying by it keeps or zeroes each line. It's compact, but MySQL-specific and less readable than CASE.
  2. The mar column uses NULL as the "else". Maya and Noah had no March lines, so their bins only received NULLs and SUM returned NULL, not 0.
  3. Some reports want NULL ("no data") rather than 0 ("data, and it was zero") — choose deliberately, or wrap with COALESCE(…, 0).
  4. And there's no shortcut keyword: SELECT * FROM orders PIVOT (COUNT(id) FOR status IN ('paid','shipped')) AS p fails with ERROR 1064 (42000) near '(COUNT(id) FOR status IN ('paid','shipped')) AS p'.

Learn more: Conditional aggregation and pivots and CASE expressions.

Order status counts per customer with a grand total — predict Zara's row

Predict the output, paying attention to Zara (no orders) and the total row.

SQL
SELECT COALESCE(c.name, 'All customers') AS customer,
  COUNT(o.id)                 AS orders,
  SUM(o.status = 'shipped')   AS shipped,
  SUM(o.status = 'paid')      AS paid,
  SUM(o.status = 'pending')   AS pending,
  SUM(o.status = 'cancelled') AS cancelled
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name WITH ROLLUP;
Show answer

Zara's order count is 0, but her status columns are NULL, not 0. WITH ROLLUP adds a final row whose name is NULL, which COALESCE relabels as "All customers".

Result
+---------------+--------+---------+------+---------+-----------+
| customer      | orders | shipped | paid | pending | cancelled |
+---------------+--------+---------+------+---------+-----------+
| Liam          |      2 |       1 |    0 |       0 |         1 |
| Maya          |      2 |       2 |    0 |       0 |         0 |
| Noah          |      1 |       0 |    1 |       0 |         0 |
| Rohit         |      2 |       1 |    1 |       0 |         0 |
| Sofia         |      1 |       0 |    0 |       1 |         0 |
| Zara          |      0 |    NULL | NULL |    NULL |      NULL |
| All customers |      8 |       4 |    2 |       1 |         1 |
+---------------+--------+---------+------+---------+-----------+
What’s happening
  1. The LEFT JOIN keeps Zara with one row where every o.* column is NULL.
  2. COUNT(o.id) skips NULLs, so her orders is 0. But o.status = 'shipped' is NULL = 'shipped', which is NULL, and SUM of only NULLs is NULL. Counts and sums disagree on empty groups.
  3. WITH ROLLUP appends a super-aggregate row over all groups, with the grouped column set to NULL. Its numbers are totals over all rows: 8 orders, 4 shipped, and so on.
  4. COALESCE(c.name, …) relabels that NULL. It's fragile: if a real customer had a NULL name, they'd be relabelled too. GROUPING(c.name) (MySQL 8.0+) returns 1 only on the rollup row, so IF(GROUPING(c.name), 'All customers', c.name) is the precise version.
  5. The row order isn't something to rely on: add ORDER BY (allowed with ROLLUP since 8.0.12), e.g. ORDER BY GROUPING(c.name), c.name, to put the total last explicitly.

With GROUPING() and COALESCE(SUM(…), 0) for the empty group:

SQL
SELECT IF(GROUPING(c.name), 'All customers', c.name) AS customer,
       COUNT(o.id) AS orders,
       COALESCE(SUM(o.status = 'shipped'), 0) AS shipped
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name WITH ROLLUP;
Result
+---------------+--------+---------+
| customer      | orders | shipped |
+---------------+--------+---------+
| Liam          |      2 |       1 |
| Maya          |      2 |       2 |
| Noah          |      1 |       0 |
| Rohit         |      2 |       1 |
| Sofia         |      1 |       0 |
| Zara          |      0 |       0 |
| All customers |      8 |       4 |
+---------------+--------+---------+
What’s happening
  1. GROUPING(c.name) is 1 on the rollup row and 0 elsewhere, so the label can't be confused with a real NULL name.
  2. COALESCE(SUM(…), 0) turns Zara's NULL into 0, which is what a status report should show.
  3. COUNT(…) never needs COALESCE: it always returns a number.

Learn more: WITH ROLLUP and Aggregate functions.

Unpivot quarterly columns into rows

Turn this wide table into one row per region per quarter, and leave out quarters with no figure.

quarterly_sales
+--------+------+------+------+------+
| region | q1   | q2   | q3   | q4   |
+--------+------+------+------+------+
| North  |  120 |  135 | NULL |  160 |
| South  |   90 |   80 |  110 |   95 |
+--------+------+------+------+------+
Show answer

The portable answer is a UNION ALL of one SELECT per column:

SQL
SELECT region, 'q1' AS quarter, q1 AS sales FROM quarterly_sales
UNION ALL SELECT region, 'q2', q2 FROM quarterly_sales
UNION ALL SELECT region, 'q3', q3 FROM quarterly_sales
UNION ALL SELECT region, 'q4', q4 FROM quarterly_sales
ORDER BY region, quarter;
Result
+--------+---------+-------+
| region | quarter | sales |
+--------+---------+-------+
| North  | q1      |   120 |
| North  | q2      |   135 |
| North  | q3      |  NULL |
| North  | q4      |   160 |
| South  | q1      |    90 |
| South  | q2      |    80 |
| South  | q3      |   110 |
| South  | q4      |    95 |
+--------+---------+-------+
What’s happening
  1. Each SELECT turns one column into rows, tagging it with a literal quarter name. The first SELECT fixes the column names (quarter, sales) for the whole union.
  2. UNION ALL, not UNION: plain UNION removes duplicate rows, which would silently merge two regions that happen to have the same figure in the same quarter — and it costs a sort.
  3. The ORDER BY at the end applies to the whole union.
  4. North's missing Q3 comes out as a NULL row; the question asked to drop it, which needs a WHERE on each branch (or the next form).
  5. It reads the table four times. With many columns, that's slow and verbose.

Alternative: LATERAL (MySQL 8.0.14+) — read each row once and expand it:

SQL
SELECT s.region, u.quarter, u.sales
FROM quarterly_sales s,
LATERAL (SELECT 'q1' AS quarter, s.q1 AS sales
         UNION ALL SELECT 'q2', s.q2
         UNION ALL SELECT 'q3', s.q3
         UNION ALL SELECT 'q4', s.q4) AS u
WHERE u.sales IS NOT NULL
ORDER BY s.region, u.quarter;
Result
+--------+---------+-------+
| region | quarter | sales |
+--------+---------+-------+
| North  | q1      |   120 |
| North  | q2      |   135 |
| North  | q4      |   160 |
| South  | q1      |    90 |
| South  | q2      |    80 |
| South  | q3      |   110 |
| South  | q4      |    95 |
+--------+---------+-------+
What’s happening
  1. For each row s, the LATERAL derived table builds four rows from s's own columns — the table is scanned once.
  2. The comma before LATERAL is a cross join, which is fine because each derived table belongs to exactly one outer row.
  3. WHERE u.sales IS NOT NULL drops North's Q3, leaving seven rows.
  4. A wide table like this is usually a design smell: storing one row per (region, quarter) from the start makes every query simpler, which is the real lesson of unpivoting.

Learn more: Set operations: UNION, INTERSECT and EXCEPT and First, second and third normal form.

A dynamic pivot: one column per order status, whatever statuses exist

Build a per-customer status table without hard-coding the status names in the query.

Show answer

Generate the column list from the data with GROUP_CONCAT, glue it into a SQL string, and run it as a prepared statement:

SQL
SET @cols = (
  SELECT GROUP_CONCAT(DISTINCT
           CONCAT('SUM(o.status = ''', status, ''') AS `', status, '`')
           ORDER BY status)
  FROM orders);
SELECT @cols;

SET @sql = CONCAT('SELECT c.name, ', @cols,
                  ' FROM customers c JOIN orders o ON o.customer_id = c.id',
                  ' GROUP BY c.id, c.name ORDER BY c.id');
PREPARE p FROM @sql;
EXECUTE p;
DEALLOCATE PREPARE p;
Result
+-----------------------------------------------------------------------------------------------------------------------------------------------------------+
| @cols                                                                                                                                                     |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------+
| SUM(o.status = 'pending') AS `pending`,SUM(o.status = 'paid') AS `paid`,SUM(o.status = 'shipped') AS `shipped`,SUM(o.status = 'cancelled') AS `cancelled` |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------+

+-------+---------+------+---------+-----------+
| name  | pending | paid | shipped | cancelled |
+-------+---------+------+---------+-----------+
| Rohit |       0 |    1 |       1 |         0 |
| Maya  |       0 |    0 |       2 |         0 |
| Liam  |       0 |    0 |       1 |         1 |
| Sofia |       1 |    0 |       0 |         0 |
| Noah  |       0 |    1 |       0 |         0 |
+-------+---------+------+---------+-----------+
What’s happening
  1. The inner CONCAT writes one column expression per distinct status, like SUM(o.status = 'paid') AS `paid`. Inside a SQL string literal, '' is an escaped single quote.
  2. GROUP_CONCAT(DISTINCT … ORDER BY status) joins them with commas. The order is pending, paid, shipped, cancelled — not alphabetical — because status is an ENUM, and ENUMs sort by their definition order, not by their text.
  3. CONCAT builds the full query text in @sql; PREPARE parses it, EXECUTE runs it. Add a new status to the ENUM and use it, and the next run grows a column with no code change.
  4. Watch group_concat_max_len (default 1024 bytes): a longer column list is cut off with only a warning — with the limit set to 20, GROUP_CONCAT(name) returned Asha,Ben,Chloe,Dev,E and warning 1260 Row 5 was cut by GROUP_CONCAT() — so the generated SQL breaks. Raise it for the session when the list can be long.
  5. The values are pasted into SQL text, so this is only safe for values you control. Never build a dynamic pivot from user-supplied strings — that's SQL injection. Often the better design is to return the long format and pivot in the application or BI tool.

Learn more: GROUP_CONCAT and JSON aggregation and SQL injection and prepared statements.

Each category's share of total revenue

Show revenue per top-level category (Electronics includes its subcategories) and each one's percentage of the total, ignoring cancelled orders.

Show answer

Electronics 91.6%, Books 8.4%. Aggregate first, then use an empty window to get the grand total on every row:

SQL
WITH cat_revenue AS (
  SELECT COALESCE(parent.name, c.name) AS top_category,
         SUM(oi.quantity * oi.unit_price) AS revenue
  FROM order_items oi
  JOIN orders o           ON o.id = oi.order_id AND o.status <> 'cancelled'
  JOIN products p         ON p.id = oi.product_id
  JOIN categories c       ON c.id = p.category_id
  LEFT JOIN categories parent ON parent.id = c.parent_id
  GROUP BY top_category
)
SELECT top_category, revenue,
       ROUND(100 * revenue / SUM(revenue) OVER (), 1) AS pct_of_total
FROM cat_revenue
ORDER BY revenue DESC;
Result
+--------------+---------+--------------+
| top_category | revenue | pct_of_total |
+--------------+---------+--------------+
| Electronics  | 3298.00 |         91.6 |
| Books        |  303.00 |          8.4 |
+--------------+---------+--------------+
What’s happening
  1. The LEFT JOIN to categories parent finds each category's parent. Laptops, Phones and Accessories have parent Electronics; Books has none, so parent.name is NULL.
  2. COALESCE(parent.name, c.name) rolls subcategories up into their parent and leaves top-level categories as themselves. This handles one level; a deeper tree needs the recursive CTE from the hierarchy questions.
  3. The CTE sums revenue per top-level category: Electronics 1798 + 799 + 701 = 3298, Books 303.
  4. SUM(revenue) OVER () — a window with no partition and no order — is the total of all rows, 3601, attached to each row. 100 × 3298 / 3601 = 91.6.
  5. The window is applied after the CTE's GROUP BY, so it sums two aggregated rows. You can even do it in one query: SUM(SUM(x)) OVER () is legal, because window functions run after grouping — run per leaf category, it gave the same percentages as the next query.

Alternative: a scalar subquery for the total (any version). Here per leaf category:

SQL
SELECT c.name AS category, SUM(oi.quantity * oi.unit_price) AS revenue,
       ROUND(100 * SUM(oi.quantity * oi.unit_price) /
             (SELECT SUM(quantity * unit_price)
              FROM order_items oi2 JOIN orders o2 ON o2.id = oi2.order_id
              WHERE o2.status <> 'cancelled'), 1) AS pct
FROM order_items oi
JOIN orders o     ON o.id = oi.order_id AND o.status <> 'cancelled'
JOIN products p   ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
GROUP BY c.id, c.name
ORDER BY revenue DESC;
Result
+-------------+---------+------+
| category    | revenue | pct  |
+-------------+---------+------+
| Laptops     | 1798.00 | 49.9 |
| Phones      |  799.00 | 22.2 |
| Accessories |  701.00 | 19.5 |
| Books       |  303.00 |  8.4 |
+-------------+---------+------+
What’s happening
  1. The subquery is uncorrelated, so it runs once and returns 3601.
  2. The trap is keeping the two filters identical: if the subquery forgot status <> 'cancelled', the total would include the cancelled MacBook (1,299) and every percentage would be too small. The window version can't drift, because it sums the same rows the report shows.
  3. The rounded percentages add up to 100.0 here, but in general rounded shares don't always sum to exactly 100 — say so if a report shows a total line.

Learn more: Window functions: OVER and PARTITION BY.

A department report that includes the empty department

For each department show headcount, minimum, maximum and average salary and total payroll. Research has nobody and must still appear, with headcount 0 and payroll 0.

Show answer

Start from departments and LEFT JOIN employees, so empty departments survive:

SQL
SELECT d.name AS department,
       COUNT(e.id)                AS headcount,
       MIN(e.salary)              AS min_salary,
       MAX(e.salary)              AS max_salary,
       ROUND(AVG(e.salary), 0)    AS avg_salary,
       COALESCE(SUM(e.salary), 0) AS payroll
FROM departments d
LEFT JOIN employees e ON e.department_id = d.id
GROUP BY d.id, d.name
ORDER BY d.id;
Result
+-------------+-----------+------------+------------+------------+-----------+
| department  | headcount | min_salary | max_salary | avg_salary | payroll   |
+-------------+-----------+------------+------------+------------+-----------+
| Engineering |         4 |  118000.00 |  185000.00 |     146750 | 587000.00 |
| Sales       |         3 |   95000.00 |  130000.00 |     108000 | 324000.00 |
| Support     |         2 |   72000.00 |   88000.00 |      80000 | 160000.00 |
| Research    |         0 |       NULL |       NULL |       NULL |      0.00 |
+-------------+-----------+------------+------------+------------+-----------+
What’s happening
  1. departments is on the left, so all four departments produce at least one row. Research's row has e.* all NULL.
  2. COUNT(e.id) counts non-NULL ids: 0 for Research. COUNT(*) would say 1, counting the padding row — see Tricky NULL and JOIN puzzles.
  3. MIN, MAX and AVG of no values are NULL, which is honest: there is no minimum salary in an empty department. Only payroll is wrapped in COALESCE, because "total spent" really is 0.
  4. Jonas (no department) is not in any row. Starting from employees instead would include him in a NULL department but drop Research — the direction of the join decides which "orphans" you see.

Learn more: LEFT and RIGHT OUTER JOIN and COUNT(*) vs COUNT(column) vs COUNT(DISTINCT).

#Tricky NULL and JOIN puzzles

Added

These are the questions where the query looks right, runs without error and returns the wrong answer. Nearly all of them come from two sources. The first is NULL, which means "unknown": any comparison with it is neither true nor false but unknown, and WHERE keeps only rows that are true. The second is join cardinality: a join produces one row per matching pair, so duplicates on either side multiply, and outer joins add NULL-padded rows that COUNT(*) happily counts.

The mental model for NULL is a sealed envelope. Is the envelope's contents equal to 5? You can't say — unknown. Is it equal to another sealed envelope? Still unknown. Is it not in the list (1, 2, sealed envelope)? You can't rule it out — unknown, so the row is dropped. The mental model for joins is handshakes: two rows from Ava's side and three from Raj's side matching on the same key make six handshakes, not five.

SQL
SELECT NULL = NULL        AS eq,
       NULL <=> NULL      AS null_safe,
       NULL IS NULL       AS is_null,
       NULL + 1           AS plus,
       CONCAT('a', NULL)  AS concat,
       CONCAT_WS('-', 'a', NULL, 'b') AS concat_ws,
       4 NOT IN (1, 2, NULL) AS not_in;
Result
+------+-----------+---------+------+--------+-----------+--------+
| eq   | null_safe | is_null | plus | concat | concat_ws | not_in |
+------+-----------+---------+------+--------+-----------+--------+
| NULL |         1 |       1 | NULL | NULL   | a-b       |   NULL |
+------+-----------+---------+------+--------+-----------+--------+
What’s happening
  1. NULL = NULL is NULL (unknown), not true. That's why you test with IS NULL, which returns a real 1 or 0.
  2. <=> is MySQL's null-safe equals: it treats two NULLs as equal and returns 1/0, never NULL. The standard spelling IS NOT DISTINCT FROM isn't supported by MySQL 9.7: WHERE city IS NOT DISTINCT FROM NULL fails with ERROR 1064 (42000) near 'DISTINCT FROM NULL'.
  3. Arithmetic and CONCAT with a NULL operand return NULL. CONCAT_WS is the exception: it skips NULL arguments.
  4. 4 NOT IN (1, 2, NULL) expands to 4 <> 1 AND 4 <> 2 AND 4 <> NULL → TRUE AND TRUE AND UNKNOWN → unknown. That single line is the cause of the most famous NULL bug, in the first question below.
  5. In a WHERE, unknown behaves like false: the row is dropped. In a CHECK constraint, unknown behaves like true: a table with CHECK (n > 0) accepted NULL but rejected -1 with ERROR 3819 (HY000): Check constraint 'chk_chk_1' is violated. Same logic, different rule for what passes.
Which employees manage nobody? (NOT IN and NULL)

Predict the result. manager_id is NULL for Asha, the CEO.

SQL
SELECT name FROM employees
WHERE id NOT IN (SELECT manager_id FROM employees);
Show answer

No rows at all — even though six employees manage nobody:

Result
Empty set

Use NOT EXISTS, which isn't affected by the NULL:

SQL
SELECT e.name
FROM employees e
WHERE NOT EXISTS (SELECT 1 FROM employees r WHERE r.manager_id = e.id);
Result
+-------+
| name  |
+-------+
| Chloe |
| Dev   |
| Farid |
| Grace |
| Isla  |
| Jonas |
+-------+
What’s happening
  1. The subquery SELECT manager_id FROM employees returns NULL, 1, 1, 2, 1, 5, 5, 1, 8, 1 — the NULL is Asha's.
  2. For Chloe (id 3): 3 NOT IN (NULL, 1, 2, 5, 8, …) means 3 <> NULL AND 3 <> 1 AND …. The first comparison is unknown, every other one is true, so the whole condition is unknown and Chloe is dropped. The same happens to every row.
  3. NOT EXISTS asks a different question: "is there a row r whose manager_id equals my id?". For Chloe there isn't, so NOT EXISTS is true. Asha's NULL simply never matches anyone and causes no harm.
  4. Ben, Elena, Hiro and Asha are correctly excluded: someone reports to each of them.
  5. Two other correct fixes: add WHERE manager_id IS NOT NULL inside the NOT IN subquery (it returned the same six names), or the anti-join LEFT JOIN employees r ON r.manager_id = e.id WHERE r.id IS NULL (same six again).

Contrast: when NOT IN is safe. "Customers who never ordered" (LeetCode's Customers Who Never Order) works with NOT IN, because orders.customer_id is NOT NULL:

SQL
SELECT name FROM customers WHERE id NOT IN (SELECT customer_id FROM orders);
Result
+------+
| name |
+------+
| Zara |
+------+
What’s happening
  1. The subquery returns 1, 1, 2, 3, 3, 4, 5, 2, with no NULL, so every <> comparison is a real true or false.
  2. Zara (id 6) differs from all of them, so she's returned. The LEFT JOIN … IS NULL and NOT EXISTS versions also returned only Zara.
  3. It's safe today. If someone later makes customer_id nullable (guest checkout), this query silently starts returning nothing. NOT EXISTS doesn't depend on the schema staying that way, which is why it's the better default.

Learn more: NULL and three-valued logic and IN vs EXISTS.

Predict: how many customers are not in Toronto?

Six customers: two in Toronto, one each in Mumbai, London and Dubai, and Sofia, whose city is NULL. Predict the count.

SQL
SELECT COUNT(*) AS not_toronto FROM customers WHERE city <> 'Toronto';
Show answer

3, not 4. Sofia's NULL city makes city <> 'Toronto' unknown, so she isn't counted.

Result
+-------------+
| not_toronto |
+-------------+
|           3 |
+-------------+
What’s happening
  1. Rohit and Noah (Toronto) give false. Maya, Liam and Zara give true.
  2. Sofia: NULL <> 'Toronto' is unknown. WHERE keeps only true rows, so she's dropped. Her row is in neither city = 'Toronto' nor city <> 'Toronto' — the two conditions don't cover the table between them.
  3. If "not Toronto" should include unknown cities, say so explicitly: WHERE city <> 'Toronto' OR city IS NULL returned 4, and WHERE NOT (city <=> 'Toronto') also returned 4.
  4. A related check: SELECT COUNT(*), COUNT(city) FROM customers returns 6 and 5 — COUNT(column) skips NULLs, which is another way the missing city disappears from reports.

Learn more: NULL and three-valued logic.

Predict: employees per department, COUNT(*) vs COUNT(e.id)

Predict both columns for Research, which has no employees.

SQL
SELECT d.name, COUNT(*) AS count_star, COUNT(e.id) AS count_id
FROM departments d
LEFT JOIN employees e ON e.department_id = d.id
GROUP BY d.id, d.name
ORDER BY d.id;
Show answer

Research gets count_star = 1 and count_id = 0.

Result
+-------------+------------+----------+
| name        | count_star | count_id |
+-------------+------------+----------+
| Engineering |          4 |        4 |
| Sales       |          3 |        3 |
| Support     |          2 |        2 |
| Research    |          1 |        0 |
+-------------+------------+----------+
What’s happening
  1. The LEFT JOIN gives Research one row, with every e.* column NULL, so that the department isn't lost.
  2. COUNT(*) counts rows in the group. Research's group has one row (the padding row), so it says 1 — a department with one phantom employee.
  3. COUNT(e.id) counts non-NULL values of e.id. The padding row's e.id is NULL, so it says 0.
  4. Rule: after an outer join, count a column from the optional side, preferably its primary key (never NULL for a real row). The same rule fixed the calendar report in Gaps and islands.

Learn more: COUNT(*) vs COUNT(column) vs COUNT(DISTINCT).

Spot the bug: the LEFT JOIN that drops customers

The goal: list every customer with their number of shipped orders, including customers with none. This returns only three customers. Why?

SQL
SELECT c.name, COUNT(o.id) AS shipped_orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'shipped'
GROUP BY c.id, c.name
ORDER BY c.id;
Show answer

The WHERE runs after the join and filters out the NULL-padded rows, which turns the LEFT JOIN back into an inner join:

Result
+-------+----------------+
| name  | shipped_orders |
+-------+----------------+
| Rohit |              1 |
| Maya  |              2 |
| Liam  |              1 |
+-------+----------------+

Move the condition into the ON clause, so it decides which orders match, not which rows survive:

SQL
SELECT c.name, COUNT(o.id) AS shipped_orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'shipped'
GROUP BY c.id, c.name
ORDER BY c.id;
Result
+-------+----------------+
| name  | shipped_orders |
+-------+----------------+
| Rohit |              1 |
| Maya  |              2 |
| Liam  |              1 |
| Sofia |              0 |
| Noah  |              0 |
| Zara  |              0 |
+-------+----------------+
What’s happening
  1. In the buggy query, the join first produces every customer–order pair plus a padding row for Zara. Sofia's only order is pending and Noah's is paid.
  2. WHERE o.status = 'shipped' then removes Sofia's and Noah's rows (wrong status) and Zara's padding row (NULL = 'shipped' is unknown). Their groups disappear entirely.
  3. In the fixed query the status test is part of the match. Sofia's pending order doesn't match, so the LEFT JOIN gives her a padding row instead — and COUNT(o.id) counts it as 0.
  4. The rule for outer joins: conditions on the optional (right-hand) table go in ON; conditions on the preserved table go in WHERE. For an inner join it makes no difference where you put them.
  5. SUM(o.status = 'shipped') with no filter at all also works, but returned NULL for Zara rather than 0, for the reason shown in Pivoting and reporting.

Learn more: Join conditions: ON vs WHERE vs USING.

Spot the bug: deposits and withdrawals that are three times too big

Ava deposited 100 and 50 and withdrew 30, 20 and 10. Raj deposited 70. Why does this report say Ava deposited 450?

SQL
SELECT a.owner, SUM(d.amount) AS deposited, SUM(w.amount) AS withdrawn
FROM accounts a
LEFT JOIN deposits d    ON d.account_id = a.id
LEFT JOIN withdrawals w ON w.account_id = a.id
GROUP BY a.id, a.owner
ORDER BY a.id;
Show answer

Joining one account to two independent child tables pairs every deposit with every withdrawal — a fan-out:

Result
+-------+-----------+-----------+
| owner | deposited | withdrawn |
+-------+-----------+-----------+
| Ava   |       450 |       120 |
| Raj   |        70 |      NULL |
+-------+-----------+-----------+

Aggregate each child table before joining, so each join is one-to-one:

SQL
SELECT a.owner,
       COALESCE(d.total, 0) AS deposited,
       COALESCE(w.total, 0) AS withdrawn
FROM accounts a
LEFT JOIN (SELECT account_id, SUM(amount) AS total
           FROM deposits GROUP BY account_id) d    ON d.account_id = a.id
LEFT JOIN (SELECT account_id, SUM(amount) AS total
           FROM withdrawals GROUP BY account_id) w ON w.account_id = a.id
ORDER BY a.id;
Result
+-------+-----------+-----------+
| owner | deposited | withdrawn |
+-------+-----------+-----------+
| Ava   |       150 |        60 |
| Raj   |        70 |         0 |
+-------+-----------+-----------+
What’s happening
  1. Ava has 2 deposits and 3 withdrawals. The joins produce 2 × 3 = 6 rows for her: each deposit appears 3 times and each withdrawal twice.
  2. So SUM(d.amount) is (100 + 50) × 3 = 450 and SUM(w.amount) is (30 + 20 + 10) × 2 = 120. Raj has one deposit and no withdrawals, so his single row is correct by luck.
  3. The fix pre-aggregates each child table in a derived table: one row per account, 150 and 60 for Ava. Joining one-row-per-account tables can't multiply anything.
  4. COALESCE(…, 0) turns Raj's missing withdrawals into 0 for the report.
  5. SUM(DISTINCT d.amount) is not a fix: it would merge two genuinely separate deposits of the same amount. A correlated subquery per column ((SELECT COALESCE(SUM(amount), 0) FROM deposits d WHERE d.account_id = a.id)) also gave 150 / 60 and 70 / 0.

The milder version of the same bug is counting orders after joining order lines: COUNT(o.id) over customers JOIN orders JOIN order_items returned 4 orders for Rohit (he has 2 orders with 2 lines each). COUNT(DISTINCT o.id) returned the correct 2 — DISTINCT is a valid fix for counting keys, just not for summing amounts.

Learn more: Joining more than two tables and Derived tables.

Predict the row counts of every join type

Predict the number of rows each join returns.

ta.k: 1, 1, 2, NULL tb.k: 1, 1, 1, 3, NULL
SQL
SELECT
 (SELECT COUNT(*) FROM ta JOIN tb ON ta.k = tb.k)        AS inner_rows,
 (SELECT COUNT(*) FROM ta LEFT JOIN tb ON ta.k = tb.k)   AS left_rows,
 (SELECT COUNT(*) FROM ta RIGHT JOIN tb ON ta.k = tb.k)  AS right_rows,
 (SELECT COUNT(*) FROM ta CROSS JOIN tb)                 AS cross_rows,
 (SELECT COUNT(*) FROM ta JOIN tb ON ta.k <=> tb.k)      AS null_safe_rows;
Show answer

Inner 6, left 8, right 8, cross 20, null-safe 7 — and a full outer join (emulated, since MySQL has no FULL JOIN) would return 10.

Result
+------------+-----------+------------+------------+----------------+
| inner_rows | left_rows | right_rows | cross_rows | null_safe_rows |
+------------+-----------+------------+------------+----------------+
|          6 |         8 |          8 |         20 |              7 |
+------------+-----------+------------+------------+----------------+
What’s happening
  1. Inner: only key 1 exists on both sides. Two 1s on the left × three 1s on the right = 6 handshakes. Key 2, key 3 and both NULLs match nothing (NULL = NULL is unknown).
  2. Left: the 6 matches, plus one padding row for each unmatched left row — key 2 and NULL — = 8.
  3. Right: the 6 matches, plus padding for tb's unmatched 3 and NULL = 8.
  4. Cross: every pair, 4 × 5 = 20. No condition at all.
  5. Null-safe join with <=>: the 6 key-1 pairs plus the two NULLs now matching each other = 7.
  6. Full outer = matches + unmatched left + unmatched right = 6 + 2 + 2 = 10. In MySQL you write it as the LEFT JOIN UNION ALL the RIGHT JOIN rows whose left side is NULL, which returned 10 — see FULL OUTER JOIN in MySQL.

NULL is treated differently by grouping and set operations. GROUP BY and UNION treat all NULLs as one value, unlike =:

SQL
SELECT k, COUNT(*) FROM ta GROUP BY k;
SELECT k FROM ta UNION SELECT k FROM tb;
SELECT COUNT(DISTINCT k) FROM ta;
Result
+------+----------+
| k    | COUNT(*) |
+------+----------+
|    1 |        2 |
|    2 |        1 |
| NULL |        1 |
+------+----------+

+------+
| k    |
+------+
|    1 |
|    2 |
| NULL |
|    3 |
+------+

+-------------------+
| COUNT(DISTINCT k) |
+-------------------+
|                 2 |
+-------------------+
What’s happening
  1. GROUP BY k puts the NULL in its own group — all NULLs together, as if they were equal.
  2. UNION removes duplicates the same way: the two tables' NULLs collapse into one NULL row. (UNION ALL would keep both.)
  3. COUNT(DISTINCT k) ignores NULL entirely: 2 distinct non-NULL values. So "how many distinct values?" has two different answers — 3 groups, 2 distinct values.
  4. The standard calls this "not distinct" (grouping) versus "not equal" (comparison), and it's exactly the difference between <=> and = in MySQL.

Learn more: INNER JOIN, CROSS JOIN and DISTINCT.

Employees with a bonus under 1,000 — including those with no bonus

Report every employee whose bonus is under 1,000, counting "no bonus" as 0. Then predict the three averages.

bonuses (employee_id, bonus)
2 Ben 5000 · 3 Chloe 800 · 5 Elena 1200 · 8 Hiro NULL   — nobody else has a row
SQL
SELECT AVG(bonus) AS avg_ignoring_null,
       AVG(COALESCE(bonus, 0)) AS avg_null_as_zero,
       COUNT(bonus) AS counted
FROM bonuses;
Show answer

The obvious LEFT JOIN … WHERE b.bonus < 1000 returns only Chloe: everyone without a bonus row has b.bonus = NULL, and NULL < 1000 is unknown. Include the NULLs explicitly:

SQL
SELECT e.name, b.bonus
FROM employees e
LEFT JOIN bonuses b ON b.employee_id = e.id
WHERE b.bonus < 1000 OR b.bonus IS NULL
ORDER BY e.id;
Result
+-------+-------+
| name  | bonus |
+-------+-------+
| Asha  |  NULL |
| Chloe |   800 |
| Dev   |  NULL |
| Farid |  NULL |
| Grace |  NULL |
| Hiro  |  NULL |
| Isla  |  NULL |
| Jonas |  NULL |
+-------+-------+
What’s happening
  1. The LEFT JOIN keeps all ten employees. Six have no bonus row; Hiro has a row whose bonus is NULL. In the joined result both look identical: b.bonus is NULL.
  2. b.bonus < 1000 is true for Chloe, false for Ben and Elena, and unknown for the seven NULLs.
  3. OR b.bonus IS NULL turns those seven unknowns into true, giving eight rows. WHERE COALESCE(b.bonus, 0) < 1000 returned the same eight.
  4. If you needed to tell "no bonus row" from "a row with an unknown bonus", test b.employee_id IS NULL — the join key is NULL only on padding rows.
  5. This is LeetCode's Employee Bonus, and the IS NULL branch is the whole point of it.

The averages differ because aggregates skip NULLs:

Result
+-------------------+------------------+---------+
| avg_ignoring_null | avg_null_as_zero | counted |
+-------------------+------------------+---------+
|         2333.3333 |        1750.0000 |       3 |
+-------------------+------------------+---------+
What’s happening
  1. AVG(bonus) averages only the three known bonuses: (5000 + 800 + 1200) / 3 = 2333.33. Hiro's NULL is left out of both the sum and the count.
  2. AVG(COALESCE(bonus, 0)) treats Hiro's bonus as 0: 7000 / 4 = 1750. SUM(bonus) / COUNT(*) also gives 1750.
  3. COUNT(bonus) is 3: the denominator AVG really used.
  4. Neither average is "right"; they answer different questions ("average of known bonuses" vs "average per row"). The interview point is knowing that AVG silently changes its denominator when NULLs appear.

Learn more: Aggregate functions and NULL and three-valued logic.

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.