4.4.5 · D4Databases

Exercises — SQL clauses — WHERE, GROUP BY, HAVING, ORDER BY, LIMIT

2,554 words12 min readBack to topic

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:

Figure — SQL clauses — WHERE, GROUP BY, HAVING, ORDER BY, LIMIT

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 sorting

Reading 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.