GROUP BY: Grouping and Aggregating Data
Concept. GROUP BY collapses every row that shares a value in the named column into a single output row, so aggregates like COUNT and AVG run once per group.
Intuition. GROUP BY sorts the 9 Listens rows into one bucket (one group) per user_id, so the 9 rows become one row per user, Mickey, Minnie, Daffy. COUNT and AVG run once per bucket: Mickey's total listens, Minnie's average rating.
Figure 1. GROUP BY collapses rows into one summary row per group: nine listen rows, colored by user identity, become one row per user_id carrying the aggregate. Nine rows, three groups, three output rows.
Simple GROUP BY
-- Count how many songs each user has listened to
SELECT user_id, COUNT(*) AS listen_count
FROM Listens
GROUP BY user_id -- one group per unique user_id value
Figure. The nine input listens (left, bucketed by user) collapse into the three-row output (right): one row per bucket, COUNT(*) = 3 each. Pluto has zero listens, so Pluto forms no bucket and appears in no output row.
-
One group per unique value
-
Aggregates work per group
-
Groups are isolated from each other
GROUP BY with Multiple Aggregates
-- Get listening stats per user: count, average, max, min ratings
SELECT
user_id,
COUNT(*) AS listen_count, -- Counts ALL rows in group (including NULLs)
AVG(rating) AS avg_rating, -- AVG ignores NULL values
MAX(rating) AS max_rating, -- Finds highest non-NULL rating
MIN(rating) AS min_rating -- Finds lowest non-NULL rating
FROM Listens
GROUP BY user_id -- Each user gets their own stats
-
All aggregates in one pass
-
Each sees only its group
-
NULLs follow aggregate rules
GROUP BY Multiple Columns
-- Count how many times each user played each song
SELECT
user_id,
song_id,
COUNT(*) AS play_count,
AVG(rating) AS avg_rating
FROM Listens
GROUP BY user_id, song_id -- multi-column GROUP BY: unique (user, song) pairs
ORDER BY user_id, song_id -- Sort output: first by user, then by song
Figure 2. The bucket key is whatever combination of columns you name. By user_id, Mickey is one bucket of three. Add song_id and each (user, song) pair is its own bucket, so Mickey splits into three single-row buckets. More columns, finer buckets.
- The bucket key is the column combination you name
GROUP BY with WHERE
-- Count high-rated songs (>4.0) per user
SELECT
user_id,
COUNT(*) AS high_rating_count,
AVG(rating) AS avg_high_rating
FROM Listens
WHERE rating > 4.0 -- WHERE filters BEFORE grouping (NULL > 4.0 = false)
GROUP BY user_id -- Groups only the filtered rows
ORDER BY user_id -- Sort by user_id ascending (default)
Step 1: WHERE rating > 4.0 · 9 rows → 5 rows (crossed-out rows fail the test; NULL is UNKNOWN, also removed)
| listen_id | user_id | rating | rating > 4.0 | |
|---|---|---|---|---|
| 1 | 1 | 4.5 | 4.5 > 4.0 | |
| 2 | 1 | 4.2 | 4.2 > 4.0 | |
| 3 | 1 | 3.9 | 3.9 > 4.0 | |
| 4 | 2 | 4.7 | 4.7 > 4.0 | |
| 5 | 2 | 4.6 | 4.6 > 4.0 | |
| 6 | 2 | 3.9 | 3.9 > 4.0 | |
| 7 | 3 | 2.9 | 2.9 > 4.0 | |
| 8 | 3 | 4.9 | 4.9 > 4.0 | |
| 9 | 3 | NULL | NULL → unknown |
Step 2: GROUP BY on Filtered Data
| user_id | COUNT(*) | AVG(rating) | Notes: Post-Filtered Rows | |
|---|---|---|---|---|
| 1 | 2 | (4.5+4.2)/2 = 4.35 | Ratings from listen_ids=1,2 | |
| 2 | 2 | (4.7+4.6)/2 = 4.65 | Ratings from listen_ids=4,5 | |
| 3 | 1 | 4.9/1 = 4.9 | Ratings from listen_id=8 |
Output
| user_id | high_rating_count | avg_high_rating | |
|---|---|---|---|
| 1 | 2 | 4.35 | |
| 2 | 2 | 4.65 | |
| 3 | 1 | 4.9 |
-
WHERE happens first
-
Form Groups for kept rows
-
Aggregates on kept data
GROUP BY with NULL Values
-- Count how many listens for each rating value (including NULL)
SELECT
rating,
COUNT(*) AS COUNT
FROM Listens
GROUP BY rating -- NULL values form their own group
ORDER BY rating -- NULLs appear first or last (DB-dependent)
NULL Grouping Behavior
| rating | Notes: Groups |
|---|---|
| 2.9 | Group: 2.9; listen_id = 7 |
| 3.9 | Group: 3.9; listen_id = 3,6 |
| 4.2 | Group: 4.2 ; listen_id = 2 |
| 4.5 | Group: 4.5 ; listen_id = 1 |
| 4.6 | Group: 4.6 ; listen_id = 5 |
| 4.7 | Group: 4.7 ; listen_id = 4 |
| 4.9 | Group: 4.9 ; listen_id = 8 |
| NULL | Group: NULL ; NULLs group together listen_id = 9 |
Output
| rating | count | Notes |
|---|---|---|
| NULL | 1 | NULL forms its own group |
| 2.9 | 1 | Single occurrence |
| 3.9 | 2 | Two occurrences |
| 4.2 | 1 | Single occurrence |
| 4.5 | 1 | Single occurrence |
| 4.6 | 1 | Single occurrence |
| 4.7 | 1 | Single occurrence |
| 4.9 | 1 | Single occurrence |
-
All NULLs = one group
-
NULLs match each other here
-
Sort puts NULLs at edges
COUNT(*) vs COUNT(column)
-- Compare total listens vs rated vs timed listens per user
SELECT
user_id,
COUNT(*) AS total_listens, -- COUNT(*) includes NULL rows
COUNT(rating) AS rated_listens, -- COUNT(column) skips NULL values
COUNT(listen_time) AS timed_listens -- Different NULLs = different counts
FROM Listens
GROUP BY user_id
ORDER BY user_id
| user_id | total_listens | rated_listens | timed_listens | Notes | |
|---|---|---|---|---|---|
| 1 | 3 | 3 | 2 | 1: has time 2: time=NULL 3: has time | |
| 2 | 3 | 3 | 2 | 4: time=NULL 5: has time 6: has time | |
| 3 | 3 | 2 | 1 | 7: time=NULL 8: has time 9: time=NULL |
COUNT Variants:
-
COUNT(*) = all rows (including NULLs)
-
COUNT(column) = non-NULL values only
-
COUNT(DISTINCT column) = unique non-NULL values
Key Rules
-
Simple Grouping:
GROUP BY user_idcreates one group per user -
Multi-Column Grouping:
GROUP BY user_id, song_idgroups by combinations -
Aggregate Scope: Functions like COUNT() operate within each group.
SELECT user_id, COUNT(*), AVG(rating) -
Execution Order: FROM → WHERE → GROUP BY → SELECT (SELECT always happens last!)
-
NULL Grouping: NULL values form their own group ⚠️
-
SELECT Restriction: Only GROUP BY columns and aggregates allowed in SELECT (else invalid: a non-aggregated column that is not in GROUP BY) ⚠️
Common Mistakes
The Non-Grouped Column Error; UNSAFE queries
-- Unsafe: song_id is in SELECT but not in GROUP BY
SELECT user_id, song_id, COUNT(*)
FROM Listens
GROUP BY user_id;
We grouped by user_id, so each user collapses to one row. But we also asked for song_id, and Mickey's one group holds three different songs. Which one goes in his single row?
The database can't pick, so it errors or silently guesses. Every ? is an ambiguous column.
The fix: group by it
You don't need another table. Add song_id to GROUP BY, and each song becomes its own group, so every song_id resolves to exactly one value.
-- Safe: every non-aggregate column is in GROUP BY
SELECT user_id, song_id, COUNT(*)
FROM Listens
GROUP BY user_id, song_id;
WHERE Can't See Aggregates
-- WRONG: WHERE can't filter on aggregates (they don't exist yet)
SELECT user_id, AVG(rating)
FROM Listens
WHERE AVG(rating) > 4.0
GROUP BY user_id
-- CORRECT: Use HAVING to filter groups after aggregation
SELECT user_id, AVG(rating)
FROM Listens
GROUP BY user_id
HAVING AVG(rating) > 4.0
Problem: WHERE executes before grouping; aggregates don't exist yet
The COUNT NULL Trap
-- Misleading: COUNT(rating) skips NULL ratings
SELECT user_id,
COUNT(rating) AS total_ratings -- Skips NULLs!
FROM Listens
GROUP BY user_id
-- Better: Show both total rows and non-NULL ratings
SELECT user_id,
COUNT(*) AS total_listens,
COUNT(rating) AS rated_listens
FROM Listens
GROUP BY user_id
Problem: COUNT(column) behavior with NULL often unexpected
Redundant DISTINCT
-- REDUNDANT: GROUP BY already ensures unique user_id values
SELECT DISTINCT user_id, COUNT(*)
FROM Listens
GROUP BY user_id
-- SIMPLER: GROUP BY alone creates unique groups
SELECT user_id, COUNT(*)
FROM Listens
GROUP BY user_id
Problem: GROUP BY already ensures unique group values