Aggregation, GROUP BY, HAVING
So far every query returned rows that already existed. Aggregation does something different: it collapses many rows into one. "How many students?", "What is the average grade?", "Which course earns the most?" — these are the questions analytics actually asks, and this chapter is where SQL starts being an analytics language rather than a lookup language.
COUNT, SUM, AVG, MIN, MAX
The five functions that cover almost everything:
| Function | Returns | Ignores NULL? |
|---|---|---|
COUNT(*) |
Number of rows | No — counts every row |
COUNT(col) |
Number of non-NULL values in col |
Yes |
SUM(col) |
Total | Yes |
AVG(col) |
Arithmetic mean | Yes |
MIN(col) / MAX(col) |
Smallest / largest | Yes |
SELECT COUNT(*) AS total_enrollments, COUNT(grade) AS graded, ROUND(AVG(grade), 2) AS avg_grade, MIN(grade) AS lowest, MAX(grade) AS highest, SUM(grade) AS total_points FROM enrollments;
| total_enrollments | graded | avg_grade | lowest | highest | total_points |
|---|---|---|---|---|---|
| 2 500 | 1 826 | 72.98 | 38 | 100 | 133 255 |
1 row
1 ms
total_enrollmentsgradedavg_gradelowesthighesttotal_points
201479.43471001112
Twenty rows in, one row out. That is the defining property of an aggregate: it consumes a set of rows and produces a single value.
COUNT(*) vs COUNT(col) vs COUNT(DISTINCT col)
Three functions that look almost identical and answer three different questions. This is asked in interviews constantly.
SELECT COUNT(*) AS rows_total, -- 10 COUNT(city) AS cities_present, -- 9 (Sevara's NULL skipped) COUNT(DISTINCT city) AS unique_cities -- 6 (NULL not counted at all) FROM students;
| rows_total | cities_present | unique_cities |
|---|---|---|
| 300 | 292 | 8 |
1 row
0 ms
ExpressionResultQuestion it answers
COUNT(*)10How many students are there?
COUNT(city)9How many students have a city recorded?
COUNT(DISTINCT city)6How many different cities do we serve?
:::tip 💡 A ready-made data-quality check
COUNT(*) - COUNT(col) is the number of NULLs in that column. One query gives you the completeness of every column at once — a genuinely useful first look at an unfamiliar table:
SELECT COUNT(*) AS rows, COUNT(*) - COUNT(city) AS missing_city, COUNT(*) - COUNT(gender) AS missing_gender FROM students;
:::
How aggregates treat NULL
Every aggregate except COUNT(*) skips NULLs silently. Usually that is exactly right — but it changes the denominator, and students must know it.
Same data, two answers, both defensible — but they mean different things. Ask the business question first: "the average of the grades we have" (79.43) or "the average score per enrollment, where ungraded counts as nothing" (55.60)? The first is almost always what is wanted; the second is almost always a bug.
SELECT SUM(grade) FROM enrollments WHERE status = 'nonexistent'; returns NULL, not 0 — there was nothing to add. If a dashboard needs a number, wrap it: COALESCE(SUM(grade), 0). COUNT is the exception: it correctly returns 0 for an empty set.
GROUP BY
GROUP BY takes the one-number-for-the-whole-table idea and applies it per group. Instead of "the average grade", you get "the average grade for each course".
SELECT course_id, COUNT(*) AS enrollments, COUNT(grade) AS graded, ROUND(AVG(grade), 2) AS avg_grade, MAX(grade) AS best FROM enrollments GROUP BY course_id ORDER BY course_id;
| course_id | enrollments | graded | avg_grade | best |
|---|---|---|---|---|
| 1 | 193 | 132 | 79.15 | 100 |
| 2 | 186 | 146 | 74.27 | 96 |
| 3 | 174 | 128 | 63.59 | 86 |
| 4 | 167 | 119 | 74.18 | 100 |
| 5 | 167 | 125 | 69.37 | 94 |
| 6 | 174 | 118 | 75.17 | 94 |
| 7 | 165 | 122 | 79.89 | 100 |
| 8 | 170 | 131 | 75.15 | 100 |
| 9 | 161 | 118 | 76.1 | 97 |
| 10 | 179 | 147 | 73.52 | 100 |
| 11 | 200 | 140 | 68.34 | 92 |
| 13 | 187 | 127 | 73.86 | 97 |
| 14 | 189 | 135 | 74.65 | 100 |
| 15 | 188 | 138 | 65.59 | 91 |
14 rows
1 ms
course_idenrollmentsgradedavg_gradebest
15468.092 25587.2100 33276.589 43280.081 54191.091
:::gotcha ⚠️ Course 6 is missing — and course 5 is misleading Two lessons in one result table:
- "Deep Learning" (course 6) does not appear at all.
GROUP BYcan only group rows that exist, and course 6 has no enrollments. A "courses report" built this way silently omits every course nobody signed up for — which is usually exactly the course management wanted to hear about. The fix is aLEFT JOINfromcourses(Chapter 5). - Course 5 shows avg 91.0 from a single grade out of four enrollments. Averages over tiny samples look authoritative and are not. Always put
COUNTnext toAVGin a report so the reader can see how much the number is worth. :::
Grouping by a text column works the same way:
SELECT status, COUNT(*) AS n FROM enrollments GROUP BY status ORDER BY n DESC;
| status | n |
|---|---|
| completed | 1 634 |
| active | 472 |
| dropped | 202 |
| failed | 192 |
4 rows
1 ms
:::tip 💡 This is value_counts()
Students coming from pandas recognise this instantly: GROUP BY col + COUNT(*) is exactly df['col'].value_counts(). Making that connection early makes the whole chapter click.
:::
And NULL forms its own group — the one place SQL treats all NULLs as equal:
SELECT COALESCE(city, '(unknown)') AS city, COUNT(*) AS students FROM students GROUP BY city ORDER BY students DESC, city; -- Tashkent 3, Samarkand 2, then Andijan/Bukhara/Fergana/Namangan/(unknown) 1 each
| city | students |
|---|---|
| Tashkent | 82 |
| Samarkand | 40 |
| Bukhara | 38 |
| Fergana | 36 |
| Namangan | 28 |
| Andijan | 24 |
| Karshi | 22 |
| Nukus | 22 |
| (unknown) | 8 |
9 rows
0 ms
The golden rule of GROUP BY
:::info 📌 The rule
Every column in SELECT must either appear in GROUP BY, or be wrapped in an aggregate function. Nothing else is allowed.
:::
The reason is simple once you look at the diagram above. After grouping, course 1 is one output row built from four input rows. Ask for student_id and the question has no answer — there were four different students. Which one should it print?
-- WRONG: student_id is neither grouped nor aggregated SELECT course_id, student_id, AVG(grade) FROM enrollments GROUP BY course_id; -- PostgreSQL: ERROR — column "student_id" must appear in the GROUP BY clause -- SQLite/MySQL: NO error — they invent an arbitrary value. Far more dangerous.
| course_id | student_id | AVG(grade) |
|---|---|---|
| 1 | 59 | 79.152 |
| 2 | 241 | 74.267 |
| 3 | 85 | 63.586 |
| 4 | 211 | 74.176 |
| 5 | 281 | 69.368 |
| 6 | 21 | 75.169 |
| 7 | 130 | 79.893 |
| 8 | 33 | 75.145 |
| 9 | 215 | 76.102 |
| 10 | 102 | 73.524 |
| 11 | 216 | 68.343 |
| 13 | 221 | 73.858 |
| 14 | 188 | 74.652 |
| 15 | 95 | 65.594 |
14 rows
1 ms
:::gotcha ⚠️ SQLite and MySQL let this through
PostgreSQL and SQL Server reject the query. SQLite and older MySQL happily return some student_id — whichever row the engine happened to touch last — with no warning at all. Students then ship a report full of values that look real and are meaningless. Teach the rule as absolute, regardless of what the engine tolerates.
:::
The three legitimate ways to get what was probably wanted:
-- (a) group by it too — one row per (course, student) SELECT course_id, student_id, AVG(grade) FROM enrollments GROUP BY course_id, student_id; -- (b) aggregate it — e.g. how many distinct students SELECT course_id, COUNT(DISTINCT student_id) AS students, AVG(grade) FROM enrollments GROUP BY course_id; -- (c) you actually wanted "the top student per course" → window function, Chapter 7
Result 1
| course_id | student_id | AVG(grade) |
|---|---|---|
| 1 | 3 | 77 |
| 1 | 6 | 84 |
| 1 | 8 | 66 |
| 1 | 10 | 75 |
| 1 | 11 | NaN |
| 1 | 12 | 93 |
| 1 | 13 | NaN |
| 1 | 14 | 77 |
| 1 | 19 | 69 |
| 1 | 20 | 77 |
| 1 | 21 | 76 |
| 1 | 22 | 77 |
| 1 | 23 | 74.333 |
| 1 | 24 | 100 |
| 1 | 26 | 96 |
… first 15 of 1866 rows
1866 rows
Result 2
| course_id | students | AVG(grade) |
|---|---|---|
| 1 | 134 | 79.152 |
| 2 | 134 | 74.267 |
| 3 | 124 | 63.586 |
| 4 | 121 | 74.176 |
| 5 | 130 | 69.368 |
| 6 | 133 | 75.169 |
| 7 | 129 | 79.893 |
| 8 | 127 | 75.145 |
| 9 | 129 | 76.102 |
| 10 | 137 | 73.524 |
| 11 | 152 | 68.343 |
| 13 | 136 | 73.858 |
| 14 | 148 | 74.652 |
| 15 | 132 | 65.594 |
14 rows
3 ms
Grouping by several columns Listing several columns makes one group per combination. The number of output rows equals the number of combinations that actually occur — not every theoretical pairing.
-- "How many students of each gender, per city?" SELECT COALESCE(city, '(unknown)') AS city, gender, COUNT(*) AS students FROM students GROUP BY city, gender ORDER BY city, gender;
| city | gender | students |
|---|---|---|
| (unknown) | F | 2 |
| (unknown) | M | 6 |
| Andijan | F | 10 |
| Andijan | M | 14 |
| Bukhara | F | 18 |
| Bukhara | M | 20 |
| Fergana | F | 16 |
| Fergana | M | 20 |
| Karshi | F | 11 |
| Karshi | M | 11 |
| Namangan | F | 15 |
| Namangan | M | 13 |
| Nukus | F | 15 |
| Nukus | M | 7 |
| Samarkand | F | 20 |
… first 15 of 18 rows
18 rows
0 ms
To get gender as columns instead of rows — a pivot table — combine SUM with CASE WHEN (Chapter 8):
sql run
SELECT COALESCE(city,'(unknown)') AS city,
SUM(CASE WHEN gender = 'M' THEN 1 ELSE 0 END) AS male,
SUM(CASE WHEN gender = 'F' THEN 1 ELSE 0 END) AS female,
COUNT(*) AS total
FROM students
GROUP BY city;
This SUM(CASE WHEN ...) pattern is one of the most-used constructs in real analytics work. Worth drilling.
HAVING vs WHERE
The rule from Chapter 1, now with the reason visible: WHERE filters rows before grouping; HAVING filters groups after grouping.
-- "Among completed enrollments only, which courses average above 80?" SELECT course_id, COUNT(*) AS completed_count, ROUND(AVG(grade), 2) AS avg_grade FROM enrollments WHERE status = 'completed' -- filters ROWS: drops active/failed/dropped GROUP BY course_id HAVING AVG(grade) > 80 -- filters GROUPS: drops low-average courses ORDER BY avg_grade DESC;
| course_id | completed_count | avg_grade |
|---|---|---|
| 7 | 120 | 80.25 |
1 row
1 ms
course_idcompleted_countavg_grade
5191.0 2587.2 1283.5
Course 4 is gone: its completed grades are 81 and 79, averaging exactly 80.0, and > 80 is strict. Course 3 averages 76.5. Note also that course 1's average jumped from 68.0 to 83.5 — because WHERE status = 'completed' removed the two failing grades first. Changing WHERE changes what the groups contain.
Both of these return the same answer:
WHERE status = 'completed' GROUP BY course_id -- fast
GROUP BY course_id HAVING status = 'completed' -- slow / invalid
The second builds every group from every row and only then throws work away — and in a strict database it is simply an error, since status is not grouped. Filter as early as possible. If a condition does not involve an aggregate, it belongs in WHERE.
sql run
SELECT student_id, course_id, COUNT(*) AS times
FROM enrollments
GROUP BY student_id, course_id
HAVING COUNT(*) > 1;
Any row that comes back is a student enrolled twice in the same course. This GROUP BY ... HAVING COUNT(*) > 1 pattern is the standard duplicate-detection query, and a very common interview task. On our clean data it returns nothing.
ROUND and the integer-division trap
The Chapter 2 trap comes back here, and in aggregation it does real damage.
-- "What percentage of enrollments are completed?" -- WRONG: 12 / 20 in integer arithmetic = 0 SELECT COUNT(*) / (SELECT COUNT(*) FROM enrollments) * 100 AS pct FROM enrollments WHERE status = 'completed'; -- returns 0 -- RIGHT: force a decimal with * 1.0 before dividing SELECT ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM enrollments), 1) AS pct FROM enrollments WHERE status = 'completed'; -- returns 60.0
Result 1
| pct |
|---|
| 0 |
1 row
Result 2
| pct |
|---|
| 65.4 |
1 row
1 ms
The compact idiom for "percentage of rows matching a condition", worth memorising:
-- pass rate per course, in one query SELECT course_id, COUNT(*) AS graded, ROUND(100.0 * SUM(CASE WHEN grade >= 60 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pass_rate_pct FROM enrollments WHERE grade IS NOT NULL GROUP BY course_id ORDER BY pass_rate_pct DESC;
| course_id | graded | pass_rate_pct |
|---|---|---|
| 1 | 132 | 98.5 |
| 7 | 122 | 98.4 |
| 13 | 127 | 96.9 |
| 6 | 118 | 96.6 |
| 9 | 118 | 94.9 |
| 10 | 147 | 94.6 |
| 4 | 119 | 93.3 |
| 14 | 135 | 92.6 |
| 2 | 146 | 91.8 |
| 8 | 131 | 90.1 |
| 5 | 125 | 88 |
| 11 | 140 | 82.9 |
| 15 | 138 | 73.2 |
| 3 | 128 | 63.3 |
14 rows
1 ms
course_idgradedpass_rate_pct
25100.0 32100.0 42100.0 51100.0 1450.0
Note the WHERE grade IS NOT NULL: without it, ungraded enrollments would sit in the denominator and drag every pass rate down. Deciding what belongs in the denominator is the actual analytical work — the SQL is the easy part.
Practice set
-- 1. How many students in each city? (unknown shown explicitly) SELECT COALESCE(city, '(unknown)') AS city, COUNT(*) AS n FROM students GROUP BY city ORDER BY n DESC; -- 2. Average price of courses per category SELECT category, COUNT(*) AS courses, ROUND(AVG(price)) AS avg_price FROM courses GROUP BY category ORDER BY avg_price DESC; -- 3. Each student's average grade, best students first SELECT student_id, COUNT(grade) AS graded, ROUND(AVG(grade), 1) AS avg_grade FROM enrollments GROUP BY student_id HAVING COUNT(grade) > 0 ORDER BY avg_grade DESC; -- 4. Courses with more than 3 enrollments SELECT course_id, COUNT(*) AS n FROM enrollments GROUP BY course_id HAVING COUNT(*) > 3; -- 5. Monthly enrollment counts SELECT strftime('%Y-%m', enrolled_at) AS month, COUNT(*) AS n FROM enrollments GROUP BY month ORDER BY month; -- 6. Completion rate per course SELECT course_id, COUNT(*) AS total, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, ROUND(100.0 * SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct FROM enrollments GROUP BY course_id ORDER BY pct DESC; -- 7. Data-quality check: NULLs per column SELECT COUNT(*) AS rows_total, COUNT(*) - COUNT(city) AS missing_city, COUNT(*) - COUNT(birth_date) AS missing_birth_date FROM students; -- 8. Any duplicate enrollments? (should return nothing) SELECT student_id, course_id, COUNT(*) AS times FROM enrollments GROUP BY student_id, course_id HAVING COUNT(*) > 1;
Result 1
| city | n |
|---|---|
| Tashkent | 82 |
| Samarkand | 40 |
| Bukhara | 38 |
| Fergana | 36 |
| Namangan | 28 |
| Andijan | 24 |
| Nukus | 22 |
| Karshi | 22 |
| (unknown) | 8 |
9 rows
Result 2
| category | courses | avg_price |
|---|---|---|
| Business | 3 | 1 366 667 |
| Math | 3 | 1 216 667 |
| Data | 4 | 1 175 000 |
| Programming | 3 | 1 166 667 |
| Design | 2 | 900 000 |
5 rows
Result 3
| student_id | graded | avg_grade |
|---|---|---|
| 197 | 3 | 87.3 |
| 1 | 3 | 86.3 |
| 116 | 2 | 84 |
| 107 | 2 | 84 |
| 6 | 8 | 82.8 |
| 270 | 2 | 82.5 |
| 176 | 4 | 82.3 |
| 103 | 5 | 82.2 |
| 276 | 4 | 81.8 |
| 248 | 9 | 81.6 |
| 247 | 3 | 81 |
| 130 | 6 | 81 |
| 199 | 4 | 80.8 |
| 167 | 6 | 80.8 |
| 298 | 3 | 80.3 |
… first 15 of 300 rows
300 rows
Result 4
| course_id | n |
|---|---|
| 1 | 193 |
| 2 | 186 |
| 3 | 174 |
| 4 | 167 |
| 5 | 167 |
| 6 | 174 |
| 7 | 165 |
| 8 | 170 |
| 9 | 161 |
| 10 | 179 |
| 11 | 200 |
| 13 | 187 |
| 14 | 189 |
| 15 | 188 |
14 rows
Result 5
| month | n |
|---|---|
| 2025-02 | 1 077 |
| 2025-03 | 131 |
| 2025-09 | 1 188 |
| 2025-10 | 104 |
4 rows
Result 6
| course_id | total | completed | pct |
|---|---|---|---|
| 10 | 179 | 139 | 77.7 |
| 7 | 165 | 120 | 72.7 |
| 2 | 186 | 134 | 72 |
| 9 | 161 | 112 | 69.6 |
| 8 | 170 | 118 | 69.4 |
| 1 | 193 | 130 | 67.4 |
| 4 | 167 | 111 | 66.5 |
| 14 | 189 | 125 | 66.1 |
| 5 | 167 | 110 | 65.9 |
| 13 | 187 | 123 | 65.8 |
| 6 | 174 | 114 | 65.5 |
| 11 | 200 | 116 | 58 |
| 15 | 188 | 101 | 53.7 |
| 3 | 174 | 81 | 46.6 |
14 rows
Result 7
| rows_total | missing_city | missing_birth_date |
|---|---|---|
| 300 | 8 | 0 |
1 row
Result 8
| student_id | course_id | times |
|---|---|---|
| 1 | 3 | 2 |
| 1 | 11 | 2 |
| 2 | 11 | 2 |
| 3 | 6 | 2 |
| 3 | 11 | 2 |
| 4 | 7 | 2 |
| 4 | 15 | 2 |
| 5 | 2 | 2 |
| 5 | 5 | 2 |
| 6 | 4 | 2 |
| 6 | 14 | 2 |
| 7 | 9 | 2 |
| 8 | 1 | 2 |
| 10 | 14 | 2 |
| 11 | 7 | 2 |
… first 15 of 514 rows
514 rows
5 ms
Interview questions
Q: COUNT(*) vs COUNT(column) vs COUNT(1)?
COUNT(*) counts rows, including rows that are entirely NULL. COUNT(column) counts only rows where that column is not NULL. COUNT(1) is identical to COUNT(*) in every modern engine — the old belief that it is faster is a myth.
Q: Does AVG include NULLs?
No. It divides by the count of non-NULL values. If you want NULLs treated as zero you must say so: AVG(COALESCE(grade, 0)) — but ask whether that is genuinely what the business means.
Q: What does SUM return over zero rows?
NULL, not 0. Wrap it in COALESCE(SUM(x), 0) when a number is required. COUNT returns 0 correctly.
Q: WHERE vs HAVING?
WHERE filters rows before GROUP BY and cannot use aggregates. HAVING filters groups after GROUP BY and can. Any condition that does not need an aggregate belongs in WHERE, because filtering earlier means less work.
Q: Can you use HAVING without GROUP BY?
Yes. The whole table is then treated as one group: SELECT AVG(grade) FROM enrollments HAVING AVG(grade) > 70; returns one row or none.
Q: Why is SELECT course_id, student_id, AVG(grade) ... GROUP BY course_id wrong?
Because a group holds many different student_id values and only one can be printed. PostgreSQL raises an error; SQLite and MySQL return an arbitrary value, which is worse — the result looks valid but is meaningless.
Q: How do you find duplicate rows?
GROUP BY the columns that should be unique, then HAVING COUNT(*) > 1.
Q: Why might a "sales by product" report be missing products?
Because GROUP BY only sees rows that exist. A product with zero sales has no rows to group. You need a LEFT JOIN from the products table to keep it in the report with a count of 0.
Summary
- Aggregates collapse many rows into one:
COUNT,SUM,AVG,MIN,MAX. COUNT(*)= rows ·COUNT(col)= non-NULL values ·COUNT(DISTINCT col)= distinct non-NULL values. Their difference is a free NULL-check.- Every aggregate except
COUNT(*)skips NULLs, which changes the denominator ofAVG.SUMover zero rows is NULL, not 0. GROUP BYmakes one output row per group. It cannot show groups that have no rows — that needs aLEFT JOIN.- Golden rule: every selected column is either in
GROUP BYor inside an aggregate. SQLite/MySQL let you break it; do not. WHEREbefore grouping,HAVINGafter. Filter as early as possible.GROUP BY ... HAVING COUNT(*) > 1is the standard duplicate finder.- Force decimals with
* 1.0or* 100.0;SUM(CASE WHEN ... THEN 1 ELSE 0 END)is the percentage idiom. - Always report
COUNTalongsideAVG, so nobody trusts an average built from one value.
Next: Chapter 5 — JOINs. Every report so far has been trapped inside a single table; JOINs are what let you say "student name and course title" in the same result.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.