DBMS 📂 SQL · 2 of 6 36 min read

Advanced SQL Practice: Aggregate Functions, GROUP BY, HAVING, and Nested Subqueries

A practice-first SQL tutorial built on one tiny, hand-verifiable dataset with deliberately planted traps. It covers the five aggregate functions and their NULL-skipping rule, GROUP BY (including the NULL group), the WHERE-vs-HAVING logical execution pipeline, and subqueries in WHERE, FROM, SELECT, and HAVING

Section 01

The Story — Five Questions That Break Beginners

The Monday Morning Sales Meeting
It's your first week as a data analyst. The manager walks in with five questions: "How many people do we employ? What does each department spend on salaries? Which departments average above 60k? Who earns more than the company average? And which department has no employees at all?"

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.


Section 02

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.

🏢 DEPARTMENTS
DeptID (PK)DeptName
D1Sales
D2Engineering
D3HR
D4Marketing
👤 EMPLOYEES
EmpID (PK)NameDeptID (FK)SalaryCommission
E1AaravD1500005000
E2DiyaD170000NULL
E3KabirD2800008000
E4MiraD290000NULL
E5RohanD2600002000
E6SaraD345000NULL
E7VeerNULL550001000
💣
Two Landmines Planted on Purpose

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.


Section 03

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.

📊 Animated — Many Rows In, One Value Out
50000 70000 80000 90000 … SUM() 450000 seven rows became one value
Aggregates change the shape of the answer: from a list of rows to a single summary value (or one value per group, once GROUP BY arrives).

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;
⚠️
The NULL Rule — Memorize This

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.


Section 04

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.

🔮 Animated — Rows Sorting Into Department Buckets
7 employee rows mixed departments D1 Sales → SUM = 120000 D2 Engg → SUM = 230000 D3 HR → SUM = 45000 NULL group → SUM = 55000 GROUP BY DeptID — note: NULLs form their own group
One output row per bucket. Veer's NULL DeptID doesn't disappear — standard SQL gathers all NULLs into a single group.
SELECT DeptID, COUNT(*) AS headcount, SUM(Salary) AS payroll
FROM   EMPLOYEES
GROUP BY DeptID;
DeptIDheadcountpayroll
D12120000
D23230000
D3145000
NULL155000
🔑
The Single-Value Rule

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


Section 05

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.

⚙️ Animated — Logical Order of Execution
FROM WHERE row filter GROUP BY HAVING group filter SELECT ORDER BY aggregates don't exist yet here aggregates available here
WHERE prunes raw rows before grouping (cheap, can use indexes). HAVING filters finished groups using aggregate results. This single picture answers a dozen interview questions.
❌ Fails — aggregate in WHERE
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.
✅ Works — aggregate in HAVING
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.
🧠
Use Both, Each for Its Job

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.


Section 06

Practice Set A — Aggregates, GROUP BY, HAVING

Solve each on paper against the Section 02 data before opening the solution.

🎯
Problem 1 · Easy
aggregate + NULL
How many employees receive a commission, and what is the average commission among those who receive one?
🎯
Problem 2 · Easy
GROUP BY + join
For every department name (not ID), show headcount and average salary, highest average first.
🎯
Problem 3 · Medium
WHERE + HAVING together
Considering only employees earning at least 50000, list departments whose such-employee headcount is greater than 1.
🔑 Solutions A — Check Your Reasoning
P1
SELECT COUNT(Commission), AVG(Commission) FROM EMPLOYEES;4 and 4000. The NULL-skipping behaviour that trips beginners is exactly what this question wants. No WHERE needed.
P2
Join first, then group by the name:
-- 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.

Section 07

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:

🔮 Animated — Inner Query Runs Once, Feeds the Outer
INNER (runs first, once) SELECT AVG(Salary) FROM EMPLOYEES 64285.71 OUTER (uses the value) WHERE Salary > 64285.71 a non-correlated (independent) subquery: the inner query never looks at the outer row
The inner result is a single number — a scalar subquery — that the outer query treats like a constant.
-- 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:

📍
In WHERE / HAVING
scalar or list
Compare against one value (>, =) or a list (IN, NOT IN, EXISTS).
📊
In FROM
derived table
The inner result acts as a temporary table — the key to multi-level aggregation (aggregate of an aggregate). Must be given an alias.
🔢
In SELECT
scalar per row
Attach a computed value to each output row — e.g. show every salary next to the company average for comparison.

Section 08

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?"

🔮 Animated — The Inner Query Re-Runs Per Outer Row
Diya · D1 · 70000 Kabir · D2 · 80000 Sara · D3 · 45000 AVG(Salary) WHERE DeptID = e.DeptID re-evaluated with each row's DeptID D1 avg = 60000 · D2 avg = 76666.67 · D3 avg = 45000 — each row is judged against its own group
Correlated = the arrow goes both ways. (Modern optimizers often rewrite this into a join — the loop is the mental model, not necessarily the execution plan.)
-- 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.

Section 09

Practice Set B — Nested Queries (Including the Famous Trap)

🔥
Problem 4 · Medium
subquery in HAVING
List departments whose average salary beats the company-wide average.
🔥
Problem 5 · Medium
IN subquery
Show the names of departments that have at least one employee — using a subquery, not a join.
🔥
Problem 6 · Hard
multi-level aggregation
What is the average department payroll — the average of each department's salary total? (Ignore employees with no department.)
😻
Problem 7 · Hard
the NOT IN trap
Find the department with no employees. The "obvious" NOT IN query returns zero rows. Why — and what's the correct query?
😻
Problem 8 · Hard
scalar subquery in SELECT
Show every employee's name, salary, and how far their salary sits from the company average (as diff).
🤔
Bonus Thought
no code
P4 and P6 both nest aggregates — one in HAVING, one in FROM. Before peeking: why can't either be written without a subquery?
🔑 Solutions B — The Reasoning Matters More Than the Code
P4
HAVING can hold a subquery — the inner average (64285.71) is computed once, then each group's average is tested against it. Only Engineering (76666.67) survives.
P5
IN tests membership in the inner list. D1, D2, D3 qualify; Marketing doesn't; Veer's NULL never matches anything, which is harmless for IN — the danger is only with NOT IN (see P7).
P6
Aggregate of an aggregate needs two passes: an inner GROUP BY builds per-department totals as a derived table, the outer AVG collapses them. (120000+230000+45000)÷3 = 131666.67.
P7
The inner list is (D1, D2, D2, D2, D3, NULL). D4 NOT IN (…, NULL) evaluates to unknown, not true — because D4 might equal the unknown value. SQL's three-valued logic silently returns nothing. Fix: filter the NULL, or use NOT EXISTS, which is NULL-safe.
P8
A scalar subquery in SELECT attaches the company average to every row; subtracting gives the distance. Diya: +5714.29, Sara: −19285.71, and so on.
Bonus
Because one query level allows only one round of aggregation. Comparing or re-aggregating aggregate results requires a second level — that is the entire reason nested queries exist.
-- 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;

Section 10

Common Mistakes — The Six That Cost Marks and Outages

🚫
Non-Grouped Column in SELECT
one row per group
Selecting Name while grouping by DeptID. Strict SQL rejects it; lenient MySQL picks an arbitrary name — a bug that looks like data.
🚫
Aggregate Inside WHERE
pipeline violation
WHERE runs before groups exist. Conditions on aggregates belong in HAVING; conditions on raw rows belong in WHERE — earlier and cheaper.
🚫
NOT IN Meets NULL
silent empty result
One NULL in the inner list and NOT IN returns zero rows with zero errors. Default to NOT EXISTS for anti-joins.
🚫
COUNT(*) vs COUNT(col)
rows vs values
COUNT(*) counts rows; COUNT(col) counts non-NULL values. Our table: 7 vs 4. Pick the one your question actually asks.
🚫
Forgetting the Derived-Table Alias
syntax error
A subquery in FROM must be named () AS t) in most engines. The error message rarely says so plainly.
🚫
Scalar Subquery Returning Many Rows
runtime error
Using = with a subquery that yields several rows crashes at runtime. If many rows are possible, you wanted IN — or the subquery is missing a filter.

Section 11

Academic vs Industry Perspective

🎓 Academic View
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.
🏭 Industry View
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.
⚖️
Where Theory Meets the Real World

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.


Section 12

Golden Rules

📝 Advanced SQL Aggregation & Subqueries — Non-Negotiable Rules
1
Replay the pipeline: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Nearly every aggregation error is a crime against this order.
2
WHERE filters rows; HAVING filters groups. If the condition needs no aggregate, put it in WHERE — earlier filtering is both correct and faster.
3
Every SELECT column is aggregated or grouped — no third option. One output row per group means one value per expression.
4
Respect the NULL rules: aggregates skip NULLs, COUNT(*) doesn't, GROUP BY makes a NULL bucket, and comparisons with NULL are unknown, not false.
5
Use NOT EXISTS for anti-joins. NOT IN with a nullable inner column silently returns nothing — the most expensive empty result in SQL.
6
One query level = one round of aggregation. Comparing aggregates to aggregates, or averaging sums, demands a subquery (in HAVING or as a derived table in FROM).
7
Verify on paper-sized data first. Compute the expected answer by hand on a 7-row table before trusting a query on 7 million — exactly as you practiced here.
8
When nesting hurts readability, reach for CTEs; when comparing rows to their group, consider window functions. Today's subqueries are the doorway to both.