Exercises — Aggregate functions — COUNT, SUM, AVG, MIN, MAX
The table below (sales) is used by most problems. Read it once now — a blank cell means NULL ("unknown / no value"), not zero.
| id | region | rep | amount |
|---|---|---|---|
| 1 | East | Alice | 100 |
| 2 | East | Bob | 200 |
| 3 | East | Alice | NULL |
| 4 | West | Carol | 300 |
| 5 | West | Carol | 300 |
| 6 | West | Dan | NULL |
| 7 | North | Eve | 50 |
Quick reminders you will need repeatedly, each stated in plain words:
COUNT(*)= count the rows themselves — a row existing is a fact, so NULL cells are still counted.COUNT(col)= count only the rows wherecolis not NULL.SUM,AVG,MIN,MAXall skip NULL silently.AVG(col)= (sum of non-NULL values) ÷ (count of non-NULL values). It never divides by the blank rows.- Over a set with zero non-NULL values:
SUM/AVG/MIN/MAXreturn NULL, butCOUNTreturns 0.

The figure above shows the amount column as a vertical strip. Notice the two greyed cells (rows 3 and 6) — the arrows show which functions walk past them.
Level 1 — Recognition
You only need to pick the right function or read a value straight off the table.
Recall Solution L1.1
WHAT the question asks: "how many rows exist" — existence of a row, regardless of whether its amount cell is filled.
WHY COUNT(*): the star means "count the rows themselves." COUNT(amount) would drop the two NULL-amount rows, giving the wrong answer to this question.
Answer: COUNT(*) → there are 7 rows.
Recall Solution L1.2
WHAT we do: scan the amount column, ignoring the two NULLs (rows 3 and 6, which are "unknown" — you cannot say an unknown is the smallest or largest honestly).
Remaining values: .
MIN = smallest = 50. MAX = largest = 300.
Recall Solution L1.3
- Filtering groups by an aggregate → ==
HAVING==. - Filtering individual rows before grouping → ==
WHERE==. WHY the split exists: in the pipeline ,WHEREruns before the aggregate exists, so it literally cannot seeSUM(amount).HAVINGruns after, so it can. (See SQL query execution order.)
Level 2 — Application
Now you compute concrete numbers.
Recall Solution L2.1
WHAT we do, step by step:
COUNT(*)counts rows → 7.COUNT(amount)counts non-NULL amounts → rows 3 and 6 dropped → 5.SUM(amount)adds non-NULL values: → 950.AVG(amount)= SUM ÷ COUNT(amount) = → 190. WHY divide by 5, not 7? AVG divides by the count of non-NULL values. The two blanks are invisible. If you wrongly divide by 7 you get — that is the classic error.
Recall Solution L2.2
WHAT COALESCE(amount,0) does: it replaces each NULL with 0 before the aggregate sees it (see COALESCE and NULLIF). So the two blanks become real zero values.
Now the column is — 7 non-NULL values.
SUM is unchanged ( adds nothing) = , but the count is now 7.
AVG = .
WHY different: turning NULL into 0 makes those rows count toward the denominator. Use this only when "missing" genuinely means "zero sales".
Recall Solution L2.3
WHAT GROUP BY region does: it splits sales into mini-tables — one per distinct region — then runs the aggregate inside each.
| region | rows | amounts (non-NULL) | SUM | COUNT | AVG |
|---|---|---|---|---|---|
| East | 1,2,3 | {100, 200} | 300 | 2 | 150 |
| West | 4,5,6 | {300, 300} | 600 | 2 | 300 |
| North | 7 | {50} | 50 | 1 | 50 |
WHY East's AVG = 150 not 100: East has 3 rows but only 2 non-NULL amounts (), so . Row 3's NULL is skipped in both SUM and COUNT.
Level 3 — Analysis
Now the tricky NULL, DISTINCT, and empty-set behaviours.
Recall Solution L3.1
WHAT DISTINCT does inside COUNT: it removes duplicates first, then counts what remains — and NULL is still excluded (see DISTINCT keyword).
repvalues: Alice, Bob, Alice, Carol, Carol, Dan, Eve → distinct set = {Alice, Bob, Carol, Dan, Eve} → 5.amountvalues (NULLs dropped): 100, 200, 300, 300, 50 → distinct set = {100, 200, 300, 50} → 4 (the two 300s collapse to one). WHY 4 not 5: the duplicate 300 (rows 4 and 5) is counted once by DISTINCT.
Recall Solution L3.2
North has one row (id 7, amount 50): SUM = 50, AVG = 50, MIN = 50, COUNT(amount) = 1.
South (zero non-NULL rows) — the important case:
SUM(amount)→ NULL (there is nothing to add; the empty sum returns NULL, not 0, in SQL).AVG(amount)→ NULL ( is undefined, so SQL returns NULL).MIN(amount)→ NULL (no value to be smallest).COUNT(amount)→ 0 (COUNT is the one that returns a number, never NULL). WHY this asymmetry: COUNT answers "how many?" — the honest answer to an empty set is zero. SUM/AVG/MIN/MAX answer "what value?" — and there is no value, so NULL ("unknown") is the honest answer. To force 0, useCOALESCE(SUM(amount), 0).
Recall Solution L3.3
WHAT we compare per region: COUNT(*) (rows) vs COUNT(DISTINCT rep) (unique reps).
| region | COUNT(*) | reps | COUNT(DISTINCT rep) | equal? |
|---|---|---|---|---|
| East | 3 | Alice,Bob,Alice | 2 | no — Alice repeats |
| West | 3 | Carol,Carol,Dan | 2 | no — Carol repeats |
| North | 1 | Eve | 1 | yes |
WHY it matters: if these two counts differ, a rep appears more than once in that region — a useful data-quality signal.
Level 4 — Synthesis
Combine WHERE, GROUP BY, HAVING, and aliases correctly.
Recall Solution L4.1
SELECT region, AVG(amount) AS avg_amt
FROM sales
GROUP BY region
HAVING AVG(amount) > 100;WHY HAVING not WHERE: the condition uses an aggregate (AVG), which does not exist until after grouping. WHERE runs earlier and would error.
From L2.3 the averages were East 150, West 300, North 50. Keep those with average > 100:
| region | avg_amt |
|---|---|
| East | 150 |
| West | 300 |
North (50) is dropped.
Recall Solution L4.2
SELECT region, SUM(amount) AS big_total
FROM sales
WHERE amount >= 100
GROUP BY region;WHY WHERE here (not HAVING): we filter individual rows by a plain column condition before grouping. WHERE is the correct, earlier stage.
Also note: WHERE amount >= 100 drops NULL amounts automatically — a comparison with NULL is never true (see NULL handling in SQL). So rows 3, 6 (NULL) and row 7 (50) are excluded.
Surviving rows: East {100,200}, West {300,300}.
| region | big_total |
|---|---|
| East | 300 |
| West | 600 |
(North has no row ≥ 100, so it disappears from the result entirely.)
Recall Solution L4.3
Two separate errors:
WHERE a > 100references an aggregate condition — butWHEREruns before aggregation, soAVG(amount)isn't computed yet. → must beHAVING.- It also references the alias
a— butSELECT(where aliases are born) runs afterWHEREin the pipeline , soWHEREcannot seeaeven if it were a plain column. Fix:
SELECT region, AVG(amount) AS a
FROM sales
GROUP BY region
HAVING AVG(amount) > 100;Result is the same as L4.1: East 150, West 300.
Level 5 — Mastery
Predict exact outputs and reason about edge behaviour.
Recall Solution L5.1
Work region by region, ORDER BY region gives alphabetical order East, North, West.
| region | rows_cnt | amt_cnt | s | a | s0 |
|---|---|---|---|---|---|
| East | 3 | 2 | 300 | 150 | 300 |
| North | 1 | 1 | 50 | 50 | 50 |
| West | 3 | 2 | 600 | 300 | 600 |
Key checks: East has 3 rows but 2 non-NULL amounts → amt_cnt=2, a = 300/2 = 150. Since no region here is all-NULL, s0 equals s everywhere (COALESCE only changes an all-NULL group's NULL into 0).
Recall Solution L5.2
SUM(amount) = 950,COUNT(*) = 7→ . This is "total divided by all rows", treating the two NULLs as if they were 0-value sales.AVG(amount) = 950/5 = 190$. This divides by the **5 known** sales. **WHICH is right for "average among known sales":**AVG(amount)` (190), because it ignores the unknowns. The figure silently assumes the blanks are zero — only valid if a missing amount truly means "no sale of value 0".
Recall Solution L5.3
SELECT rep,
COUNT(*) AS n_rows,
COALESCE(SUM(amount), 0) AS total
FROM sales
GROUP BY rep
HAVING COUNT(*) > 1
ORDER BY rep;WHY HAVING COUNT(*) > 1: we filter groups by row count — an aggregate condition, so it belongs in HAVING.
Group by rep:
- Alice: rows 1,3 → amounts {100, NULL} → n=2, SUM=100.
- Bob: row 2 → n=1 → dropped (not > 1).
- Carol: rows 4,5 → {300,300} → n=2, SUM=600.
- Dan: row 6 → n=1 → dropped.
- Eve: row 7 → n=1 → dropped.
| rep | n_rows | total |
|---|---|---|
| Alice | 2 | 100 |
| Carol | 2 | 600 |
Alice's total is 100 (her NULL row is skipped by SUM); COALESCE wasn't needed here since she has one non-NULL amount, but it protects any rep who happened to be all-NULL.
Connections
- GROUP BY and HAVING — every L3–L5 problem depends on grouping then filtering.
- NULL handling in SQL — the engine of nearly every trap here.
- COALESCE and NULLIF — turning NULL into 0 in L2.2, L5.1, L5.3.
- SQL query execution order — why L4.3 fails.
- DISTINCT keyword — L3.1, L3.3.
- Window functions — next step: aggregates that don't collapse rows.