Exercises — SQL clauses — WHERE, GROUP BY, HAVING, ORDER BY, LIMIT
Throughout, our toy table is employees with columns:
| column | meaning |
|---|---|
id |
unique number per person |
name |
the person's name (text) |
dept |
department, e.g. 'Sales' |
salary |
yearly pay, a number (may be NULL = unknown) |
The logical processing order we lean on the whole way:

The number over each clause is when the engine runs it, not when you type it. A clause can only "see" things produced by lower-numbered clauses. That single rule solves almost every exercise below.
Level 1 — Recognition
(Can you point at the right clause?)
L1.1 — Name the clause
For each job, name the ONE clause that does it:
(a) drop rows where salary is below 50000;
(b) show at most 10 rows;
(c) sort output biggest salary first;
(d) keep only departments whose average salary exceeds 60000.
Recall Solution
(a) WHERE — a per-row test on salary.
(b) LIMIT — caps the number of returned rows.
(c) ORDER BY salary DESC — sorting is ORDER BY's only job.
(d) HAVING — "average salary" is an aggregate of a group, so it must run after GROUP BY. WHERE cannot see averages.
L1.2 — True or false
"WHERE runs before GROUP BY." True or false, and why?
Recall Solution
TRUE. WHERE is step 2, GROUP BY is step 3. WHERE filters raw rows first; then the survivors get grouped. This is exactly why WHERE cannot mention COUNT(*) — no groups exist at step 2.
Level 2 — Application
(Write the correct small query.)
L2.1 — Filter then count
Write a query returning, per department, the department name and the number of employees earning more than 40000. Ignore rows where salary IS NULL.
Recall Solution
SELECT dept, COUNT(*) AS n
FROM employees
WHERE salary > 40000
GROUP BY dept;Why: salary > 40000 is a row fact → WHERE (step 2), cheaply shrinking the data before grouping. Note WHERE salary > 40000 also silently discards NULL salaries, because NULL > 40000 evaluates to NULL (not TRUE), and WHERE keeps only TRUE rows. Then GROUP BY partitions the survivors by dept, and COUNT(*) counts each pile.
L2.2 — Big departments only
Return departments that have at least 5 employees (any salary), sorted by headcount, biggest first.
Recall Solution
SELECT dept, COUNT(*) AS headcount
FROM employees
GROUP BY dept
HAVING COUNT(*) >= 5
ORDER BY headcount DESC;Why each clause: No per-row filter needed, so no WHERE. GROUP BY makes the piles. COUNT(*) >= 5 is a group fact → HAVING (step 4). headcount is the alias born in SELECT (step 5), and ORDER BY (step 6) runs after SELECT, so the alias is legal there.
L2.3 — Fix the alias bug
This query errors. Fix it two different ways.
SELECT name, salary * 12 AS annual
FROM employees
WHERE annual > 600000;Recall Solution
Why it errors: annual is defined in SELECT (step 5), but WHERE is step 2 — it runs before SELECT, so the alias does not exist yet.
Fix A — repeat the expression in WHERE:
SELECT name, salary * 12 AS annual
FROM employees
WHERE salary * 12 > 600000;Fix B — wrap in a subquery (compute the alias first, filter outside):
SELECT * FROM (
SELECT name, salary * 12 AS annual FROM employees
) t
WHERE annual > 600000;Fix A is simpler; Fix B (Subqueries and CTEs) is handy when the expression is long.
Level 3 — Analysis
(Explain the difference / spot the flaw.)
L3.1 — WHERE vs HAVING give different answers
These two queries look similar but return different numbers. Explain each result.
-- Query A
SELECT dept, COUNT(*) AS n FROM employees
WHERE salary > 50000 GROUP BY dept;
-- Query B
SELECT dept, COUNT(*) AS n FROM employees
GROUP BY dept HAVING COUNT(*) > 3;Recall Solution
Query A first removes rows with salary <= 50000 (or NULL), then counts what's left per department. So n = number of high earners per dept, and departments with zero high earners simply vanish (they produce an empty group, which disappears).
Query B keeps all rows, groups by dept, then removes whole piles smaller than 4 people. n = the full department size, shown only for departments of size .
Key contrast: WHERE changes which rows enter the count; HAVING changes which finished groups survive. They filter at different stages, so they answer different questions.
L3.2 — The silent LIMIT bug
A colleague says "LIMIT 3 gives the 3 highest-paid employees":
SELECT name, salary FROM employees LIMIT 3;Is this correct? Explain precisely.
Recall Solution
Incorrect. LIMIT (step 7) just keeps the first 3 rows of whatever order it received. With no ORDER BY, SQL gives no guarantee about order — it may hand back rows in storage/index order, which can look sorted by luck. The correct "top 3 by pay":
SELECT name, salary FROM employees
ORDER BY salary DESC
LIMIT 3;Now ORDER BY (6) sorts before LIMIT (7) trims — the two steps are adjacent in the pipeline, which is exactly why "top-N" works. (Relates to Pagination patterns: LIMIT n OFFSET m only makes sense on a stable ORDER BY.)
L3.3 — NULL breaks equality
Why does this return zero rows even though some salaries are missing?
SELECT name FROM employees WHERE salary = NULL;Recall Solution
NULL means "unknown". In SQL's three-valued logic, salary = NULL evaluates to NULL, not TRUE. WHERE keeps a row only when its predicate is exactly TRUE, so every row is dropped. The correct test uses the special operator:
SELECT name FROM employees WHERE salary IS NULL;IS NULL returns TRUE/FALSE (never NULL), so it actually finds the missing values.
Level 4 — Synthesis
(Build a complete multi-clause query from a word problem.)
L4.1 — Full pipeline
"Among departments, considering only employees with a known salary above 30000, show each such department's name and its average salary rounded to the nearest integer, but only for departments with more than 2 qualifying employees. Show the top 3 by average salary, highest first; break ties by department name alphabetically."
Recall Solution
SELECT dept, ROUND(AVG(salary)) AS avg_sal
FROM employees
WHERE salary > 30000 -- per-row filter (also drops NULLs)
GROUP BY dept -- one pile per department
HAVING COUNT(*) > 2 -- keep piles with 3+ qualifiers
ORDER BY avg_sal DESC, dept ASC -- sort, alias legal here
LIMIT 3; -- top 3 after sortingReading the word problem into the pipeline (in run order):
- "salary above 30000 / known" → WHERE
salary > 30000(NULL fails the>test, so unknowns are excluded automatically). - "each department" → GROUP BY dept.
- "more than 2 qualifying employees" → HAVING COUNT(*) > 2 (a group count → HAVING, not WHERE).
- "average salary rounded" → SELECT ROUND(AVG(salary)).
- "top ... highest first, tie-break by name" → ORDER BY avg_sal DESC, dept ASC (alias visible because ORDER BY runs after SELECT).
- "top 3" → LIMIT 3 (last).
L4.2 — Hand-execute the pipeline
Given this tiny table, compute the exact output of the L4.1 query by walking each stage.
| id | dept | salary |
|---|---|---|
| 1 | Sales | 40000 |
| 2 | Sales | 60000 |
| 3 | Sales | 20000 |
| 4 | Sales | 50000 |
| 5 | Eng | 90000 |
| 6 | Eng | 70000 |
| 7 | Eng | NULL |
| 8 | HR | 45000 |
| 9 | HR | 55000 |
Recall Solution
Step WHERE salary > 30000: row 3 (20000) drops, row 7 (NULL) drops. Survivors: 1,2,4 (Sales), 5,6 (Eng), 8,9 (HR).
Step GROUP BY dept:
- Sales = {40000, 60000, 50000}, count 3.
- Eng = {90000, 70000}, count 2.
- HR = {45000, 55000}, count 2.
Step HAVING COUNT(*) > 2: only Sales (count 3) survives; Eng and HR (count 2) are removed.
Step SELECT ROUND(AVG(salary)): Sales avg .
Step ORDER BY / LIMIT 3: one row remains — no reordering needed.
Output:
| dept | avg_sal |
|---|---|
| Sales | 50000 |
The interesting lesson: the NULL and the low salary quietly vanished at WHERE, and both other departments fell at HAVING — so a large-looking table produced a single row.
Level 5 — Mastery
(Subtle edge cases and reasoning under pressure.)
L5.1 — Two averages, one pitfall
On the L4.2 table, compute the overall average salary two ways and explain why they differ:
(a) SELECT AVG(salary) FROM employees;
(b) the average of the per-department averages from SELECT dept, AVG(salary) FROM employees GROUP BY dept;.
Recall Solution
(a) Row-level average. AVG ignores NULLs, so it averages the 8 known salaries:
(b) Average of averages.
- Sales avg .
- Eng avg (NULL ignored) .
- HR avg .
- Mean of the three .
Why different: . The overall average weights every person equally; the average-of-averages weights every department equally, so the small 2-person Eng dept (high pay) counts as much as the 4-person Sales dept. Averaging averages is not the same as the overall average unless all groups are the same size.
L5.2 — COUNT(*) vs COUNT(column) with NULLs
On the L4.2 table, what does each return for the Eng department, and why?
(a) COUNT(*) (b) COUNT(salary)
Recall Solution
Eng rows are ids 5 (90000), 6 (70000), 7 (NULL).
(a) COUNT(*) = 3 — counts rows, NULLs included.
(b) COUNT(salary) = 2 — counts non-NULL values of the column, so the NULL (id 7) is skipped.
Lesson: COUNT(*) answers "how many rows?"; COUNT(col) answers "how many have a value here?". (See aggregate functions.) Choosing the wrong one silently over- or under-counts.
L5.3 — Pagination correctness
You paginate results 5 per page with ORDER BY salary DESC LIMIT 5 OFFSET 5 (page 2). Two employees have identical salary 60000 straddling the page boundary. What can go wrong, and how do you fix it?
Recall Solution
What goes wrong: With a non-unique sort key, the relative order of the two 60000 rows is not defined. Between page 1 and page 2 requests the engine may order them differently, so one row can appear on both pages or neither — a classic pagination bug.
Fix: Add a unique tiebreaker to make the ordering total:
SELECT name, salary FROM employees
ORDER BY salary DESC, id ASC
LIMIT 5 OFFSET 5;Now (salary, id) is unique per row, so every page boundary is deterministic and stable across requests. (Foundation of Pagination patterns; keyset pagination goes further by filtering WHERE (salary, id) < (...).)
Recall One-line summary of the whole page
Every exercise reduces to "which numbered stage can see this thing?" — rows at WHERE (2), groups at HAVING (4), aliases only from SELECT (5) onward, and any "top-N" needs a total ORDER BY before LIMIT.