The Story — Five Questions That Break Beginners
The first question is one function. The second needs GROUP BY. The third needs HAVING — and will silently fail if you try WHERE. The fourth is impossible without a subquery, because SQL won't let you compare a row to an aggregate directly. And the fifth hides the most famous trap in SQL: a single NULL that makes NOT IN return nothing, with no error message.
By the end of this practice session, you will answer all five — and know exactly why each wrong version fails. That "why" is what interviews actually test.
This is a practice-first tutorial: a tiny dataset you can verify by hand, short theory bursts with animated diagrams, then eight graded problems with full solutions — covering aggregate functions, GROUP BY, HAVING, and nested queries from scalar subqueries to correlated EXISTS.
The Playground — Two Tables, Verifiable by Hand
Every problem uses this dataset. It is deliberately small enough that you can compute every answer on paper first — the fastest way to catch a wrong query.
| DeptID (PK) | DeptName |
|---|---|
| D1 | Sales |
| D2 | Engineering |
| D3 | HR |
| D4 | Marketing |
| EmpID (PK) | Name | DeptID (FK) | Salary | Commission |
|---|---|---|---|---|
| E1 | Aarav | D1 | 50000 | 5000 |
| E2 | Diya | D1 | 70000 | NULL |
| E3 | Kabir | D2 | 80000 | 8000 |
| E4 | Mira | D2 | 90000 | NULL |
| E5 | Rohan | D2 | 60000 | 2000 |
| E6 | Sara | D3 | 45000 | NULL |
| E7 | Veer | NULL | 55000 | 1000 |
Veer has no department (DeptID is NULL) and Marketing has no employees. Plus, three Commission values are NULL. These aren't accidents — they are the exact conditions that make real-world aggregate and subquery bugs appear. Watch for them throughout.
Warm-Up — The Five Aggregate Functions (and the NULL Rule)
An aggregate function collapses many rows into a single value: COUNT, SUM, AVG, MIN, MAX.
Run the warm-up on EMPLOYEES and check every number by hand:
SELECT COUNT(*) AS total_rows, -- 7
COUNT(Commission) AS with_commission, -- 4 (NULLs skipped!)
SUM(Salary) AS payroll, -- 450000
AVG(Salary) AS avg_salary, -- 64285.71
AVG(Commission) AS avg_comm, -- 4000 (16000/4, NOT 16000/7)
MIN(Salary) AS lowest, -- 45000
MAX(Salary) AS highest -- 90000
FROM EMPLOYEES;
Every aggregate ignores NULLs — except COUNT(*), which counts rows, not values. That's why AVG(Commission) is 16000÷4 = 4000, not 16000÷7. If you want NULLs treated as zero, say so explicitly: AVG(COALESCE(Commission, 0)) ≈ 2285.71. Two different business questions, two different queries.
GROUP BY — From One Summary to One Summary Per Group
GROUP BY partitions rows into buckets that share a value, then runs the aggregates once per bucket.
SELECT DeptID, COUNT(*) AS headcount, SUM(Salary) AS payroll
FROM EMPLOYEES
GROUP BY DeptID;
| DeptID | headcount | payroll |
|---|---|---|
| D1 | 2 | 120000 |
| D2 | 3 | 230000 |
| D3 | 1 | 45000 |
| NULL | 1 | 55000 |
Every column in SELECT must be either inside an aggregate or listed in GROUP BY. Why? Each group outputs one row — so every selected expression must have exactly one value per group. SELECT Name, SUM(Salary) … GROUP BY DeptID is illegal: which employee's name should a 3-person group print? (MySQL with ONLY_FULL_GROUP_BY disabled will pick one arbitrarily — a silent bug, not a feature.)
WHERE vs HAVING — The Pipeline That Explains Everything
The most-asked interview question on this topic has a one-diagram answer. SQL evaluates a query in a fixed logical order — and WHERE runs before groups exist, while HAVING runs after.
| SELECT DeptID FROM EMPLOYEES WHERE AVG(Salary) > 60000 GROUP BY DeptID; Error: aggregates are not allowed in WHERE — no groups exist yet when WHERE runs. |
| SELECT DeptID, AVG(Salary) FROM EMPLOYEES GROUP BY DeptID HAVING AVG(Salary) > 60000; Returns D2 (76666.67). D1 averages exactly 60000 — not greater than — so it's excluded. |
WHERE filters rows; HAVING filters groups. When a condition doesn't need an aggregate, put it in WHERE even if HAVING would also work — filtering early means the database groups less data. Performance and correctness point the same way.
Practice Set A — Aggregates, GROUP BY, HAVING
Solve each on paper against the Section 02 data before opening the solution.
-- P2: department name, headcount, average salary
SELECT d.DeptName, COUNT(*) AS headcount, AVG(e.Salary) AS avg_salary
FROM EMPLOYEES e
JOIN DEPARTMENTS d ON d.DeptID = e.DeptID
GROUP BY d.DeptName
ORDER BY avg_salary DESC;
-- Engineering 3 76666.67 | Sales 2 60000 | HR 1 45000
-- Veer (NULL dept) drops out via the inner JOIN; Marketing has no rows to join.
-- P3: WHERE prunes rows first, HAVING filters the finished groups
SELECT DeptID, COUNT(*) AS big_earners
FROM EMPLOYEES
WHERE Salary >= 50000
GROUP BY DeptID
HAVING COUNT(*) > 1;
-- D1 (Aarav 50000 + Diya 70000) and D2 (all three).
-- Sara (45000) was removed by WHERE before grouping, so D3 never had a chance.
Subqueries — When One Query Isn't Enough
A subquery (nested query) is a complete SELECT inside another statement. The classic need: "Who earns more than the company average?" You cannot write WHERE Salary > AVG(Salary) — the pipeline says aggregates don't exist at WHERE time. So you compute the average in an inner query first:
-- Who earns more than the company average?
SELECT Name, Salary
FROM EMPLOYEES
WHERE Salary > (SELECT AVG(Salary) FROM EMPLOYEES);
-- Diya 70000, Kabir 80000, Mira 90000 (average is 64285.71)
Subqueries can sit in three places — each with a different shape of result:
Correlated Subqueries & EXISTS — The Inner Loop
A correlated subquery references the outer row, so conceptually it re-runs for each candidate row. It answers per-row questions like "does this employee earn more than the average of their own department?"
-- Employees earning more than their OWN department's average
SELECT e.Name, e.Salary, e.DeptID
FROM EMPLOYEES e
WHERE e.Salary > (SELECT AVG(e2.Salary)
FROM EMPLOYEES e2
WHERE e2.DeptID = e.DeptID);
-- Diya (70000 > 60000), Kabir (80000 > 76666.67), Mira (90000 > 76666.67)
-- Sara equals her average; Veer's NULL DeptID makes the comparison NULL → excluded.
-- EXISTS: departments that employ at least one person earning over 75000
SELECT d.DeptName
FROM DEPARTMENTS d
WHERE EXISTS (SELECT 1 FROM EMPLOYEES e
WHERE e.DeptID = d.DeptID AND e.Salary > 75000);
-- Engineering. EXISTS stops at the first match — it checks existence, not values.
Practice Set B — Nested Queries (Including the Famous Trap)
-- P4: departments beating the company average
SELECT DeptID, AVG(Salary) AS dept_avg
FROM EMPLOYEES
GROUP BY DeptID
HAVING AVG(Salary) > (SELECT AVG(Salary) FROM EMPLOYEES);
-- P5: departments having at least one employee (subquery style)
SELECT DeptName FROM DEPARTMENTS
WHERE DeptID IN (SELECT DeptID FROM EMPLOYEES);
-- P6: average of per-department payrolls (derived table needs an alias!)
SELECT AVG(dept_total) AS avg_dept_payroll
FROM (
SELECT DeptID, SUM(Salary) AS dept_total
FROM EMPLOYEES
WHERE DeptID IS NOT NULL
GROUP BY DeptID
) AS t; -- 131666.67
-- P7 (broken): returns NOTHING because the inner list contains a NULL
SELECT DeptName FROM DEPARTMENTS
WHERE DeptID NOT IN (SELECT DeptID FROM EMPLOYEES); -- 0 rows!
-- P7 (correct): NOT EXISTS is NULL-safe
SELECT d.DeptName FROM DEPARTMENTS d
WHERE NOT EXISTS (SELECT 1 FROM EMPLOYEES e
WHERE e.DeptID = d.DeptID); -- Marketing
-- P8: scalar subquery in the SELECT list
SELECT Name, Salary,
Salary - (SELECT AVG(Salary) FROM EMPLOYEES) AS diff
FROM EMPLOYEES;
Common Mistakes — The Six That Cost Marks and Outages
Academic vs Industry Perspective
| Aggregation extends relational algebra with a grouping operator; GROUP BY partitions a relation and applies aggregate functions to each partition. |
| WHERE-before-HAVING is not convention but a consequence of the logical evaluation order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. |
| NULL comparisons follow three-valued logic (true / false / unknown) — the formal root of the NOT IN trap and the aggregate NULL-skipping rule. |
| Correlated subqueries are definable as nested iteration but are equivalent to joins in expressive power — the basis of decorrelation theory. |
| This exact toolkit powers daily reporting: dashboards, KPIs, revenue-by-region, "top customers by spend" — aggregation is the bread and butter of analytics SQL. |
| Filter early: a condition that can live in WHERE should, so less data reaches the (expensive) grouping stage. Optimizers help, but don't rely on rescue. |
| Production code prefers NOT EXISTS over NOT IN, and explicit COALESCE over implicit NULL behaviour — readability is incident-prevention. |
| Deeply nested queries get rewritten as CTEs (WITH clauses) for readability, and repeated per-group comparisons often become window functions — your natural next topics. |
Academia explains why the pipeline and three-valued logic behave as they do; industry turns that into habits — filter early, prefer NULL-safe constructs, name your derived tables, and reach for a CTE the moment nesting hurts readability. The engineers who debug fastest are the ones who can replay the pipeline in their head.