GROUP BY: Grouping and Aggregating Data

Concept. GROUP BY puts rows with the same values in the named columns into one group and produces one output row per group. Aggregate functions like COUNT and AVG compute once per group.

Intuition. GROUP BY user_id drops the 9 Listens rows into buckets, one per user; each bucket is a group. The 9 rows become one row per user: Mickey, Minnie, Daffy. COUNT and AVG run once per group: 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 produces one output row per group: nine listen rows collapse to three rows keyed by user_id, each carrying that group's aggregate values.


Simple GROUP BY

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

Figure 2. Nine input listens collapse into three output rows, one per group, COUNT(*) = 3 each. Pluto has zero listens, so Pluto forms no group and appears in no output row.


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.

Figure 3. One pass computes all four aggregates, and each sees only its own group's rows. COUNT(*) counts every row; AVG, MAX, and MIN skip the NULL.


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 groups. By user_id alone, Mickey's three listens are one group of three rows, COUNT(*) = 3. Add song_id and that group splits into three groups, (1,1), (1,2) and (1,6), one row each, COUNT(*) = 1.

Figure 4. 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.


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

In general, WHERE filters rows first, GROUP BY groups only the rows that survive, and the aggregates see only those rows.


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
2.91Single occurrence
3.92Two occurrences
4.21Single occurrence
4.51Single occurrence
4.61Single occurrence
4.71Single occurrence
4.91Single occurrence
NULL1NULL forms its own group

In general, all NULLs fall into one group, even though NULL = NULL is UNKNOWN in a WHERE test. ORDER BY puts that group first or last, depending on the database.


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 → HAVING → SELECT → ORDER BY → LIMIT. SELECT runs fifth, after the groups exist.

  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

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.

Figure 5. GROUP BY user_id leaves one row per user, but song_id was never grouped. Mickey's group holds songs 1, 2, and 6, so the database cannot pick one song_id for his row: it errors or silently guesses.

Fix: add song_id to GROUP BY. Each (user, song) pair 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