Skip to content

Aggregation, GROUP BY, HAVING

Chapter 4 16 code blocks 3704 words

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
sql8 lines
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_enrollmentsgradedavg_gradelowesthighesttotal_points
2 5001 82672.9838100133 255

1 row

1 ms

total_enrollments graded avg_grade lowest highest total_points
20 14 79.43 47 100 1112

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.

sql5 lines
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_totalcities_presentunique_cities
3002928

1 row

0 ms

Expression Result Question it answers
COUNT(*) 10 How many students are there?
COUNT(city) 9 How many students have a city recorded?
COUNT(DISTINCT city) 6 How many different cities do we serve?
💡 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:

sql4 lines
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.

AVG(grade) over 20 enrollmentsgrades present : 14 values, summing to 1112 grades missing : 6 NULLs AVG(grade) = 1112 / 14 = 79.43 ← NULLs skipped entirely SUM(grade)/COUNT() = 1112 / 20 = 55.60* ← NULLs treated as zero

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.

⚠️ SUM of nothing is NULL, not 0

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

1. rows 2. grouped 3. one row per group course 1 · 92 course 2 · 88 course 1 · 75 course 2 · 95 course 1 · 58 course 2 · 70 course 1 · 47 course 2 · 100 course 2 · 83 course 1 9275 5847 course 2 8895 70100 83 course 1 COUNT 4 · AVG 68.00 course 2 COUNT 5 · AVG 87.20
sql9 lines
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_idenrollmentsgradedavg_gradebest
119313279.15100
218614674.2796
317412863.5986
416711974.18100
516712569.3794
617411875.1794
716512279.89100
817013175.15100
916111876.197
1017914773.52100
1120014068.3492
1318712773.8697
1418913574.65100
1518813865.5991

14 rows

1 ms

course_id enrollments graded avg_grade best
1 5 4 68.0 92
2 5 5 87.2 100
3 3 2 76.5 89
4 3 2 80.0 81
5 4 1 91.0 91
⚠️ 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 BY can 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 a LEFT JOIN from courses (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 COUNT next to AVG in a report so the reader can see how much the number is worth.

Grouping by a text column works the same way:

sql4 lines
SELECT status, COUNT(*) AS n
FROM enrollments
GROUP BY status
ORDER BY n DESC;
statusn
completed1 634
active472
dropped202
failed192

4 rows

1 ms

💡 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:

sql5 lines
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
citystudents
Tashkent82
Samarkand40
Bukhara38
Fergana36
Namangan28
Andijan24
Karshi22
Nukus22
(unknown)8

9 rows

0 ms

The golden rule of GROUP BY

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

python7 lines
-- 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_idstudent_idAVG(grade)
15979.152
224174.267
38563.586
421174.176
528169.368
62175.169
713079.893
83375.145
921576.102
1010273.524
1121668.343
1322173.858
1418874.652
159565.594

14 rows

1 ms

⚠️ 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:

python9 lines
-- (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_idstudent_idAVG(grade)
1377
1684
1866
11075
111NaN
11293
113NaN
11477
11969
12077
12176
12277
12374.333
124100
12696

… first 15 of 1866 rows

1866 rows

Result 2

course_idstudentsAVG(grade)
113479.152
213474.267
312463.586
412174.176
513069.368
613375.169
712979.893
812775.145
912976.102
1013773.524
1115268.343
1313673.858
1414874.652
1513265.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.

python8 lines
-- "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;
citygenderstudents
(unknown)F2
(unknown)M6
AndijanF10
AndijanM14
BukharaF18
BukharaM20
FerganaF16
FerganaM20
KarshiF11
KarshiM11
NamanganF15
NamanganM13
NukusF15
NukusM7
SamarkandF20

… first 15 of 18 rows

18 rows

0 ms

💡 Turning it into a real cross-tab

To get gender as columns instead of rows — a pivot table — combine SUM with CASE WHEN (Chapter 8):

sql6 lines
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;
citymalefemaletotal
(unknown)628
Andijan141024
Bukhara201838
Fergana201636
Karshi111122
Namangan131528
Nukus71522
Samarkand202040
Tashkent354782

9 rows

0 ms

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.

Where each one sitsFROM → get the rows WHERE → drop rows (no aggregates available yet — no groups exist) GROUP BY → build the groups HAVING → drop whole groups (aggregates available — groups now exist) SELECT → build the output ORDER BY → sort
python10 lines
-- "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_idcompleted_countavg_grade
712080.25

1 row

1 ms

course_id completed_count avg_grade
5 1 91.0
2 5 87.2
1 2 83.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.

⚠️ Don't put a row condition in HAVING

Both of these return the same answer:

python2 lines
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.

💡 A useful one-liner: find duplicates
sql4 lines
SELECT student_id, course_id, COUNT(*) AS times
FROM enrollments
GROUP BY student_id, course_id
HAVING COUNT(*) > 1;
student_idcourse_idtimes
132
1112
2112
362
3112
472
4152
522
552
642
6142
792
812
10142
1172

… first 15 of 514 rows

514 rows

1 ms

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.

python11 lines
-- "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:

Percentage of a group matching a conditionROUND(100.0 * SUM(CASE WHEN condition THEN 1 ELSE 0 END) / COUNT(*), 1)
python9 lines
-- 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_idgradedpass_rate_pct
113298.5
712298.4
1312796.9
611896.6
911894.9
1014794.6
411993.3
1413592.6
214691.8
813190.1
512588
1114082.9
1513873.2
312863.3

14 rows

1 ms

course_id graded pass_rate_pct
2 5 100.0
3 2 100.0
4 2 100.0
5 1 100.0
1 4 50.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

python41 lines
-- 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

cityn
Tashkent82
Samarkand40
Bukhara38
Fergana36
Namangan28
Andijan24
Nukus22
Karshi22
(unknown)8

9 rows

Result 2

categorycoursesavg_price
Business31 366 667
Math31 216 667
Data41 175 000
Programming31 166 667
Design2900 000

5 rows

Result 3

student_idgradedavg_grade
197387.3
1386.3
116284
107284
6882.8
270282.5
176482.3
103582.2
276481.8
248981.6
247381
130681
199480.8
167680.8
298380.3

… first 15 of 300 rows

300 rows

Result 4

course_idn
1193
2186
3174
4167
5167
6174
7165
8170
9161
10179
11200
13187
14189
15188

14 rows

Result 5

monthn
2025-021 077
2025-03131
2025-091 188
2025-10104

4 rows

Result 6

course_idtotalcompletedpct
1017913977.7
716512072.7
218613472
916111269.6
817011869.4
119313067.4
416711166.5
1418912566.1
516711065.9
1318712365.8
617411465.5
1120011658
1518810153.7
31748146.6

14 rows

Result 7

rows_totalmissing_citymissing_birth_date
30080

1 row

Result 8

student_idcourse_idtimes
132
1112
2112
362
3112
472
4152
522
552
642
6142
792
812
10142
1172

… 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 of AVG. SUM over zero rows is NULL, not 0.
  • GROUP BY makes one output row per group. It cannot show groups that have no rows — that needs a LEFT JOIN.
  • Golden rule: every selected column is either in GROUP BY or inside an aggregate. SQLite/MySQL let you break it; do not.
  • WHERE before grouping, HAVING after. Filter as early as possible.
  • GROUP BY ... HAVING COUNT(*) > 1 is the standard duplicate finder.
  • Force decimals with * 1.0 or * 100.0; SUM(CASE WHEN ... THEN 1 ELSE 0 END) is the percentage idiom.
  • Always report COUNT alongside AVG, 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.

Write on Telegram Contents