HAVING: Filtering Group Aggregates

Concept. HAVING filters whole groups after GROUP BY, testing aggregate results like COUNT or AVG. WHERE filters rows before grouping; HAVING filters groups after.

Intuition. "Users averaging above 4.0" cannot use WHERE, since no single row holds an average. Group the Listens by user, then HAVING AVG(rating) > 4.0 keeps the user groups that pass.

HAVING filters groups: the query GROUP BY user_id HAVING AVG(rating) > 4.0 forms one row per user (Mickey COUNT 3 AVG 4.2, Minnie 4.4, Daffy 3.9), then HAVING tests each group's average; Mickey and Minnie pass, Daffy 3.9 is below 4.0 so the whole group drops. Output: Mickey, Minnie.

Figure 1. HAVING is the gatekeeper on groups. Groups form with their aggregates, HAVING tests each one, and a group either passes whole or drops whole. Three groups enter, two survive.

Simple HAVING

-- Find users whose average rating exceeds 4.0
SELECT                         -- Last: Select columns to output
    user_id,
    COUNT(*) AS listen_count,
    AVG(rating) AS avg_rating
FROM Listens                   -- First: Get rows
GROUP BY user_id               -- Next: Create groups
HAVING AVG(rating) > 4.0       -- Then: Filter groups by aggregate condition
ORDER BY user_id               -- Last: Sort surviving groups

Step 1: GROUP BY

user_idCOUNT(*)AVG(rating)Notes
13(4.5+4.2+3.9)/3User 1's
23(4.7+4.6+3.9)/3User 2's
33(2.9+4.9+NULL)/2User 3's

Step 2: HAVING AVG() > 4

user_idAVG(rating)AVG > 4.0
14.24.2 > 4.0
24.44.4 > 4.0
33.93.9 > 4.0

Output

user_idlisten_countavg_rating
134.2
234.4
  • GROUP BY first, HAVING second

  • FALSE = entire group gone

  • No partial elimination


HAVING with COUNT

-- Find songs played at least twice
SELECT 
    song_id,
    COUNT(*) AS play_count,
    AVG(rating) AS avg_rating
FROM Listens
GROUP BY song_id               -- Group by each song
HAVING COUNT(*) >= 2           -- Keep only groups with 2+ rows
ORDER BY song_id               -- Sort output

HAVING COUNT(*) >= 2 on listens grouped by song_id: songs 1, 2 and 6 were played at least twice and stay; songs 7 and 8 were played once and drop. Output: songs 1, 2, 6.

Figure. Grouping listens by song_id and keeping only groups with COUNT(*) >= 2. Songs 1, 2 and 6 were played at least twice; songs 7 and 8 were played once, so they drop.

Key Points:

  • Unpopular songs eliminated

  • Threshold: 2+ plays

  • NULLs counted in COUNT(*)


HAVING with Multiple Conditions

-- Find active users (3+ listens) with high average ratings (>4.0)
SELECT 
    user_id,
    COUNT(*) AS listen_count,
    AVG(rating) AS avg_rating,
    MIN(rating) AS min_rating
FROM Listens
GROUP BY user_id
HAVING COUNT(*) >= 3           -- One HAVING clause, two conditions joined by AND
   AND AVG(rating) > 4.0       -- Both must be true for group to survive
ORDER BY user_id

HAVING COUNT(*) >= 3 AND AVG(rating) > 4.0: both conditions must hold. Mickey and Minnie pass both; Daffy has three listens but his 3.9 average fails the rating test, so his group drops. Output: Mickey, Minnie.

Figure. Two HAVING conditions joined by AND: a group survives only if it passes both. Mickey and Minnie clear COUNT(*) >= 3 and AVG(rating) > 4.0; Daffy has three listens but his 3.9 average fails the second test.

Key Points:

  • Mix any aggregates

WHERE and HAVING

WHERE filters rows, then HAVING filters groups. The query WHERE rating > 3.5 GROUP BY user_id HAVING COUNT(*) >= 2 first strikes Daffy's 2.9 and NULL on the raw rows (9 to 7), groups the survivors into Mickey 3, Minnie 3, Daffy 1, then HAVING drops Daffy's one-row group. Output: Mickey, Minnie.

Figure 2. WHERE and HAVING run at different times. WHERE strikes individual rows before grouping (Daffy's 2.9 and NULL go), the survivors form groups, then HAVING drops whole groups by aggregate (Daffy's single row fails COUNT(*) >= 2). WHERE picks rows, HAVING picks groups.

-- Find users with 2+ ratings above 3.5

SELECT 
    user_id,
    COUNT(*) AS high_rating_count,
    AVG(rating) AS avg_high_rating
FROM Listens
WHERE rating > 3.5             -- 1st: WHERE filters individual rows
GROUP BY user_id               -- 2nd: GROUP BY forms groups from survivors
HAVING COUNT(*) >= 2           -- 3rd: HAVING filters entire groups
ORDER BY user_id               -- 4th: ORDER BY sorts final results

Step 1: WHERE rating > 3.5 · 9 rows → 7 rows (crossed-out rows fail the test; NULL is UNKNOWN, also removed)

listen_iduser_idratingrating > 3.5
114.54.5 > 3.5
214.24.2 > 3.5
313.93.9 > 3.5
424.74.7 > 3.5
524.64.6 > 3.5
623.93.9 > 3.5
732.92.9 > 3.5
834.94.9 > 3.5
93NULLNULL → unknown

Step 2: GROUP BY on Filtered Data

user_idFiltered RowsCOUNT(*)AVG(rating)
11,2,334.2
24,5,634.4
3814.9

Step 3: HAVING COUNT(*) >= 2 · 3 groups → 2 rows (HAVING crosses out groups that are too small)

user_idCOUNT(*)COUNT(*) >= 2
133 >= 2
233 >= 2
311 >= 2

Output

user_idhigh_rating_countavg_high_rating
134.2
234.4

Key Rules

  1. Execution Order: HAVING runs after GROUP BY.

  2. Aggregate Conditions: Can use aggregate functions like AVG(), COUNT()

  3. Group-Level Filter: Operates on entire groups, not individual rows

  4. Requires GROUP BY: HAVING only makes sense with grouped data

SQL Execution Pipeline

Input:  FROM
Logic:  WHERE → GROUP BY → HAVING
        (no aggregates) → (form groups) → (aggregates exist!)
Output: SELECT/ORDER BY

Common Patterns

  • Aggregate Filters: HAVING COUNT(*) > 2 keeps groups with 3+ rows

  • Average Checks: HAVING AVG(rating) > 4.0 filters by group average

  • Multiple Conditions: HAVING COUNT(*) > 1 AND AVG(rating) > 4.0

  • Column References: Can use GROUP BY columns or aggregates

Common Mistakes

HAVING on rows

-- WRONG: HAVING does not work on individual rows
SELECT * FROM Listens
HAVING rating > 4.0

-- CORRECT: Use WHERE to filter individual rows
SELECT * FROM Listens
WHERE rating > 4.0

Problem: HAVING only works with grouped data

The HAVING Alias Problem

-- WRONG: Alias not yet created when HAVING runs
SELECT user_id, AVG(rating) AS avg_r
FROM Listens
GROUP BY user_id
HAVING avg_r > 4.0

-- CORRECT: Repeat the aggregate expression in HAVING
SELECT user_id, AVG(rating) AS avg_r
FROM Listens
GROUP BY user_id
HAVING AVG(rating) > 4.0

Problem: HAVING executes before SELECT creates aliases

Wrong Filter, Wrong Place

-- WRONG: HAVING can't access individual row values
SELECT user_id, COUNT(*)
FROM Listens
GROUP BY user_id
HAVING rating > 4.0            -- Error: Which rating? Group has many!

-- CORRECT: WHERE for row conditions, HAVING for group conditions
SELECT user_id, COUNT(*)
FROM Listens
WHERE rating > 4.0             -- Filter rows BEFORE grouping
GROUP BY user_id
HAVING COUNT(*) > 2            -- Then filter groups by aggregate

Problem: HAVING should use aggregate functions or GROUP BY columns

NULL Aggregate Confusion

-- Surprising: AVG ignores NULL ratings
SELECT user_id, AVG(rating)
FROM Listens
GROUP BY user_id
HAVING AVG(rating) > 4.0       -- AVG skips NULLs (may mislead!)

-- Explicit: Show total vs rated counts for clarity
SELECT user_id, 
       COUNT(*) AS total,       -- Includes NULL rows
       COUNT(rating) AS rated,  -- Excludes NULL ratings
       AVG(rating) AS avg_rating
FROM Listens
GROUP BY user_id
HAVING COUNT(rating) > 0       -- Ensure at least 1 non-NULL rating
   AND AVG(rating) > 4.0       -- Then check average

Problem: AVG ignores NULL, which affects HAVING conditions