GROUP BY: Grouping and Aggregating Data
Concept. GROUP BY partitions the input rows by the named column values and produces one output row per partition. Aggregate functions like COUNT and AVG compute once per partition.
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 produces one output row per group: nine listen rows become three rows keyed by user_id, each row carries aggregate values. 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 group key consists of the listed columns. GROUP BY user_id forms one group for Mickey with three rows. GROUP BY user_id, song_id forms three groups for Mickey, one per (user, song) pair, and each group contains one row.
- 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