Question bank — Aggregate functions — COUNT, SUM, AVG, MIN, MAX
Picture the pipeline first
Before any trap makes sense, you need the logical query pipeline burned into your eyes. This is the fixed order in which the database thinks about a query — not the order you typed it. Almost every "why is this an error?" answer on this page points back to this one picture.

Watch aggregates skip NULLs
The single most common trap is forgetting that NULL means "unknown" and gets silently dropped from SUM, AVG, MIN, MAX, and COUNT(column) — but a row still exists, so COUNT(*) still counts it. The figure below walks one column through both paths.

Notice the divergence this creates with real numbers:

Three flavours of COUNT
COUNT(*), COUNT(column), and COUNT(DISTINCT column) answer three genuinely different questions. Fix the picture in your head and you'll never mix them up.

How GROUP BY treats NULL
Here is the twist that catches everyone: aggregates skip NULL, but GROUP BY keeps NULL as its own single group key. So a NULL department doesn't vanish — all NULL-dept rows huddle into one "NULL" bucket. The figure contrasts the two opposite behaviours side by side.

Edge cases, at a glance
The abstract boundary rules (zero rows, one-row groups, all-NULL groups) are easiest to trust once you see all three side by side with their actual return values.

True or false — justify
Each line is a claim. Decide true or false, then justify in one breath before revealing.
COUNT(*) and COUNT(column) always return the same number.
COUNT(*) counts rows (empty desks included); COUNT(column) skips NULLs. Any NULL in that column makes COUNT(column) smaller.AVG(x) always equals SUM(x) / COUNT(*).
AVG divides by COUNT(x) (non-NULL count), not COUNT(*). They agree only when no value is NULL; with a NULL present, COUNT(*) is too big and gives a wrong, too-small average.SUM of a column that is entirely NULL returns 0.
COUNT returns 0 over an empty/all-NULL set; SUM and AVG return NULL to signal "nothing to work with".Adding one NULL row to a table never changes AVG(salary).
COUNT(*) by one, which is why SUM/COUNT(*) would drift.MIN(salary) can return NULL even when some salaries are numbers.
MIN/MAX ignore NULLs, so as long as at least one non-NULL value exists, they return a real number. They return NULL only when every value is NULL.WHERE salary > AVG(salary) is a valid line inside a simple query.
HAVING, depending on whether you're filtering rows or groups.Using GROUP BY dept guarantees the result has one row per department.
COUNT(DISTINCT col) includes NULL as one of its distinct values.
DISTINCT inside COUNT still obeys the NULL-skip rule, so NULL is never counted as a value. Only distinct non-NULL values are tallied.AVG(COALESCE(salary, 0)) gives the same answer as AVG(salary).
COALESCE turns NULLs into real zeros, so they now get counted. This lowers the average because the denominator grew and zeros were added to the sum.A multi-column GROUP BY dept, team makes one group per department.
ROLLUP(dept, team) produces the same rows as a plain GROUP BY dept, team.
ROLLUP adds subtotal rows (per-dept totals and a grand total) on top of the detailed groups, marking the rolled-up columns with NULL to signal "all values".Spot the error
Each query below has a conceptual bug. Say what breaks and why.
SELECT dept, salary FROM employees GROUP BY dept; — what's illegal?
salary is neither aggregated nor in GROUP BY. Each dept group holds many salary values, so the database can't pick one — standard SQL rejects this mixing of a row-level column with a collapsed group.SELECT dept, AVG(salary) FROM employees WHERE AVG(salary) > 200 GROUP BY dept; — fix it.
HAVING, not WHERE. WHERE runs before GROUP BY, so AVG(salary) doesn't exist yet. Move AVG(salary) > 200 into a HAVING clause after GROUP BY.SELECT AVG(salary) AS a FROM employees WHERE a > 100; — why the error?
a is defined in SELECT, which runs after WHERE in the pipeline. WHERE can't see names that haven't been created yet, so a is unknown at that point.SELECT COUNT(salary) FROM employees; intending to count all employees — the trap?
COUNT(*).SELECT SUM(salary)/COUNT(*) FROM employees; intending the average salary — the flaw?
AVG(salary) (or SUM/COUNT(salary)), which divides by the non-NULL count.SELECT dept, COUNT(*) FROM employees GROUP BY dept HAVING salary > 100; — what's wrong?
salary values no longer exist — only group-level summaries do. HAVING must reference an aggregate like MAX(salary) > 100, not a bare row column.SELECT dept, team, AVG(salary) FROM employees GROUP BY dept; — why does it fail?
team is selected but not grouped or aggregated. In a multi-column grouping situation, every non-aggregated SELECT column must appear in GROUP BY; add team to the GROUP BY list.Why questions
Why does COUNT(*) count NULL rows but COUNT(col) doesn't?
COUNT(*) asks "how many rows exist?" — a row's existence is a fact even if a cell is blank. COUNT(col) asks "how many known values does this column have?", and NULL means unknown, so it's skipped. (See the "Three flavours of COUNT" figure — the empty cell survives one path and vanishes from the other.)Why do SUM and AVG return NULL over an empty set but COUNT returns 0?
Why can HAVING see aggregates while WHERE cannot?
Why is AVG never just "sum divided by the number of rows you see"?
Why does DISTINCT go inside the aggregate as COUNT(DISTINCT x), not outside?
DISTINCT inside COUNT strips duplicate values first, then tallies what remains; placing it elsewhere wouldn't scope it to that aggregate.Why does GROUP BY come before HAVING but after WHERE in execution?
Why does GROUP BY keep NULL as a group but SUM throws it away?
GROUP BY asks "which rows share a key?" — and "having no key" is itself a shared trait, so NULLs cluster into one group. SUM asks "what do these values add to?" — and an unknown can't be added, so it's dropped. (The GROUP-BY-NULL figure shows both rules living on the same data.)Edge cases
A table with zero rows — what does COUNT(*) return, and SUM(x)?
COUNT(*) returns 0 (there are zero rows), while SUM(x) returns NULL (no values to add). This mirrors the empty-set rule shown in the edge-case figure.A GROUP BY on a table where every group has exactly one row — is aggregating pointless?
AVG equals that value, MIN=MAX, and COUNT(*)=1 per group. It's valid, just returns the rows nearly unchanged.If a whole department's salaries are all NULL, what does its AVG(salary) show in a grouped query?
Does GROUP BY create a group for the NULL value of the grouping column?
MIN and MAX on a text column — do they work, and how?
SUM of a column mixing large positives and negatives that cancel to zero — is that the same as an all-NULL SUM?
0. An all-NULL SUM returns NULL, meaning "no values at all". The two look similar but signal completely different situations.If you wrap COALESCE(SUM(x), 0), what boundary case are you defending against?
SUM returns NULL. COALESCE swaps that NULL for a clean 0 so downstream math or displays don't break on an unexpected NULL.In GROUP BY ROLLUP(dept, team), what do the extra NULLs in the result mean?
team column of a rollup row means "this is the total across all teams in that department".How does CUBE(dept, team) differ from ROLLUP(dept, team) in what totals it adds?
ROLLUP gives an ordered hierarchy of subtotals (per dept, then grand total). CUBE adds subtotals for every combination of the grouping columns — per dept, per team, and the grand total — so you also get per-team totals across all departments.Recall One-line summary of the whole trap-space
Almost every aggregate bug traces back to two facts: (1) aggregates skip NULLs but COUNT(*) counts rows, and (2) the pipeline order FROM → WHERE → GROUP BY → aggregate → HAVING → SELECT → ORDER BY decides who can see what.
Connections
- Parent topic — Aggregate functions
- NULL handling in SQL — the skip-NULL rule behind most of these traps.
- GROUP BY and HAVING — grouping and group-level filtering.
- SQL query execution order — why WHERE ≠ HAVING.
- COALESCE and NULLIF — defending against NULL from SUM/AVG.
- DISTINCT keyword — deduping inside COUNT.