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.

GROUP BY collapses nine listen rows into three group cards, one per user, each carrying COUNT and AVG.

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

GROUP BY user_id collapses nine Listens rows into three buckets, one per user, each with COUNT three. Pluto has no listens, so Pluto gets no bucket and no output row.

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

GROUP BY user_id groups nine listens into three rows, each carrying COUNT, AVG, MAX and MIN of the ratings; AVG, MAX and MIN ignore the NULL.

  • 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

Multi-column GROUP BY makes finer buckets. By user_id alone, Mickey's three listens are one bucket of three rows, COUNT(*) = 3. Add song_id and that bucket splits into three buckets, (1,1), (1,2) and (1,6), one row each, COUNT(*) = 1.

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_iduser_idratingrating > 4.0
114.54.5 > 4.0
214.24.2 > 4.0
313.93.9 > 4.0
424.74.7 > 4.0
524.64.6 > 4.0
623.93.9 > 4.0
732.92.9 > 4.0
834.94.9 > 4.0
93NULLNULL → unknown

Step 2: GROUP BY on Filtered Data

user_idCOUNT(*)AVG(rating)Notes: Post-Filtered Rows
12(4.5+4.2)/2 = 4.35Ratings from listen_ids=1,2
22(4.7+4.6)/2 = 4.65Ratings from listen_ids=4,5
314.9/1 = 4.9Ratings from listen_id=8

Output

user_idhigh_rating_countavg_high_rating
124.35
224.65
314.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

ratingNotes: Groups
2.9Group: 2.9; listen_id = 7
3.9Group: 3.9; listen_id = 3,6
4.2Group: 4.2 ; listen_id = 2
4.5Group: 4.5 ; listen_id = 1
4.6Group: 4.6 ; listen_id = 5
4.7Group: 4.7 ; listen_id = 4
4.9Group: 4.9 ; listen_id = 8
NULLGroup: NULL ; NULLs group together
listen_id = 9

Output

ratingcountNotes
NULL1NULL forms its own group
2.91Single occurrence
3.92Two occurrences
4.21Single occurrence
4.51Single occurrence
4.61Single occurrence
4.71Single occurrence
4.91Single 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_idtotal_listensrated_listenstimed_listensNotes
13321: has time
2: time=NULL
3: has time
23324: time=NULL
5: has time
6: has time
33217: 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

  1. Simple Grouping: GROUP BY user_id creates one group per user

  2. Multi-Column Grouping: GROUP BY user_id, song_id groups by combinations

  3. Aggregate Scope: Functions like COUNT() operate within each group. SELECT user_id, COUNT(*), AVG(rating)

  4. Execution Order: FROM → WHERE → GROUP BY → SELECT (SELECT always happens last!)

  5. NULL Grouping: NULL values form their own group ⚠️

  6. 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?

After GROUP BY user_id, the result has one row per user with COUNT three. The query also asks for song_id, which was never grouped, so every song_id is a grey question mark: Mickey collapsed songs 1, 2, 6, so the database cannot pick one.

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