S038 · Aggregations — GROUP BY, HAVING, Subqueries
Turn a million rows into one insight. Learn the aggregate functions (COUNT/SUM/AVG/PERCENTILE), the GROUP BY mental model of ‘rows into buckets’, the WHERE-vs-HAVING trap that breaks half of all ad-hoc SQL, the three shapes of subqueries, and why COUNT(DISTINCT) on a billion rows is a code smell (reach for HLL).
🎯 Compute correct summaries from row-level data using GROUP BY, HAVING, and subqueries — and know why COUNT(DISTINCT) at scale is a red flag.
Why this session exists
Every dashboard you've ever seen is an aggregation query. Revenue by month, DAU, top-5 products, p95 latency — all SUM/COUNT/AVG + GROUP BY. The bugs in this area are so specific that every senior data engineer has seen the same three: the WHERE/HAVING mix-up, the SUM-over-empty-set returning NULL, and the average-of-averages disaster. Learn GROUP BY deeply and you can build 80% of any BI tool with raw SQL.
- Write GROUP BY queries with the right aggregate functions and the right filtering clause (WHERE vs HAVING).
- Explain the SQL execution order and why every column in SELECT must appear in GROUP BY or an aggregate.
- Handle NULLs in aggregates correctly (SUM/AVG skip them, COUNT(*) counts them, empty SUM is NULL).
- Use the three shapes of subquery: scalar, IN-list, correlated.
- Recognise when COUNT(DISTINCT) at scale should be replaced by HyperLogLog approximate distinct.
Prerequisites
- S036 — SQL Basics — SELECT/WHERE/ORDER BY/LIMIT.
- S037 — Joins — you'll aggregate joined data, and hit the multiplication bug again.
(a) Intuition · 5 min
A library records every entry — one row per person per timestamp. Nobody wants to read a million rows. The board wants: how many people entered per day? Which day was busiest? Which section did most kids visit?
To produce that report you (a) put the rows into buckets (‘all entries on 2026-05-14’), (b) apply a function to each bucket (‘count them’, ‘average dwell time’), (c) return one row per bucket. That's exactly what SQL's GROUP BY + aggregate functions do.
Think of `GROUP BY dept` as ‘put rows into buckets, one per distinct dept’. `AVG(salary)` says ‘for each bucket, average the salary column’. Output: one row per bucket. You lose row-level detail after GROUP BY — that's the whole point.
WHERE filters rows before bucketing. HAVING filters buckets after aggregation. Swap them and your report is wrong. This is the most common ad-hoc SQL bug on Earth.
- COUNT(*) — every row, including nulls. COUNT(col) — non-null values only. COUNT(DISTINCT col) — distinct non-null values.
- SUM / AVG / MIN / MAX — silently ignore nulls. SUM over an empty set returns NULL, not 0. Always COALESCE.
- STRING_AGG / GROUP_CONCAT / ARRAY_AGG — collect the group's values into a single string or array.
- PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) — real median and p95. Every DB has this in modern versions.
- APPROX_COUNT_DISTINCT (HLL) — replaces exact COUNT(DISTINCT) at scale with ~2 % accuracy in constant memory.
- 1974SEQUEL includes GROUP BYIn the original System R paper. Aggregate + grouping is core SQL from day one.
- 1992SQL-92 formalises HAVINGStandardises post-aggregation filtering. Also introduces standard join syntax.
- 1999SQL:1999 adds GROUPING SETS / CUBE / ROLLUPMulti-dimensional aggregation in one query. The foundation of OLAP cubes.
- 2003PERCENTILE_CONT / PERCENTILE_DISCTrue median and p95 without dirty hacks. Every modern engine supports these.
- 2007Flajolet's HyperLogLog paperAPPROX_COUNT_DISTINCT at any scale in constant memory. Now built into BigQuery, Snowflake, Redshift, Presto.
(b) Visual walkthrough · 15 min
The employees table (reused from S036)
| id | name | dept | salary |
|---|---|---|---|
| 1 | Alice | Eng | 120000 |
| 2 | Bob | Eng | 95000 |
| 3 | Carol | HR | 80000 |
| 4 | Dan | Eng | 140000 |
| 5 | Eve | Sales | 110000 |
| 6 | Frank | Sales | 60000 |
| 7 | Grace | HR | NULL |
The GROUP BY execution flow
Query:
SELECT dept, COUNT(*) AS headcount, AVG(salary) AS avg_salary
FROM employees
GROUP BY dept
ORDER BY avg_salary DESC;Result:
| dept | headcount | avg_salary |
|---|---|---|
| Eng | 3 | 118333 |
| Sales | 2 | 85000 |
| HR | 2 | 80000 |
Note HR shows headcount=2 (Carol + Grace) but avg_salary=80000 because Grace's NULL was ignored by AVG. Different aggregates handle NULLs differently — internalise this.
WHERE vs HAVING — the cheat you must have on tap
Row filter, runs BEFORE GROUP BY
- Removes individual rows from the input
- Cannot reference aggregate functions
- Can use indexed columns → cheap
- Ex: `WHERE hired > '2020-01-01'`
- Ex: `WHERE dept IN ('Eng','Sales')`
Group filter, runs AFTER aggregation
- Removes entire groups from the result
- Can (and usually does) reference aggregates
- Cannot benefit from an index — always O(groups)
- Ex: `HAVING COUNT(*) >= 2`
- Ex: `HAVING AVG(salary) > 90000`
The three shapes of subquery
Every subquery you'll ever write falls into one of these
The Big Three aggregation bugs
Filtering rows when you meant groups (or vice versa). Symptom: totally wrong count.
Not zero. Dashboards silently show blank cells. Wrap every SUM/COUNT that might be empty in COALESCE(x, 0).
Averaging per-region AOV then averaging those = wrong global AOV when regions have different order counts. Always aggregate from the row-level fact.
"COUNT(*) and COUNT(column) are the same thing — they both count the rows."
COUNT(*) counts rows. COUNT(col) counts rows where col IS NOT NULL. Every aggregate except COUNT(*) skips NULLs, which means AVG(col) divides by the non-NULL count, not the row count — and that is almost never the average someone asked for.
Because on a column with no NULLs the two are identical, and most columns you first meet are NOT NULL. The gap appears exactly when data quality degrades — a new optional field, a failed upstream join, a partial backfill — which is precisely when someone is looking at the metric to find out what went wrong. The aggregate quietly redefines its own denominator at the worst possible moment.
Same column, four aggregates, four different denominators:
-- table t has 5 rows; score is NULL in 2 of them, values 10, 20, 30
SELECT COUNT(*) AS rows_total, -- 5
COUNT(score) AS non_null, -- 3
SUM(score) AS total, -- 60
AVG(score) AS avg_nonnull, -- 20.0 (60/3)
SUM(score) / COUNT(*) AS avg_allrows -- 12.0 (60/5)
FROM t;
-- AVG over an empty set returns NULL, not 0 - and NULL + anything = NULL
SELECT AVG(score) FROM t WHERE 1 = 0; -- NULL
SELECT COUNT(*) FROM t WHERE 1 = 0; -- 0Why can SUM, COUNT, MIN and MAX be computed in a distributed system with almost no data movement, while COUNT(DISTINCT ...) and MEDIAN cannot?
- 1In a distributed engine each partition can compute a local result, and the coordinator must combine those partial results into the global one.forced by · data is already spread across nodes; moving it is the expensive operation, computing locally is nearly free
- 2That works only if the aggregate is algebraic: there exists a fixed-size partial state and a merge function such that merging partials equals aggregating the whole.forced by · if the partial state must grow with the data, you have not reduced anything and the shuffle returns
- 3
SUMandCOUNTqualify with a single number as state;AVGqualifies with the pair (sum, count);MIN/MAXqualify with one value.forced by · each has an associative, commutative merge over a constant-size state - 4
COUNT(DISTINCT x)does not: merging two partial counts is impossible without knowing which values overlap, so the exact partial state is the full set of distinct values seen.forced by · distinctness is a global property; two nodes cannot resolve overlap without exchanging the values themselves - 5Median is worse: it needs the value at a global rank, which cannot be determined from any partial summary of bounded size.forced by · rank depends on every other value in the dataset, not on a summary of them
Therefore the algebraic aggregates reduce in-place and shuffle only tiny partial states, while distinct-counts and quantiles are holistic and require either a full shuffle of values or an approximation.
And note what this predicts: sketch algorithms exist precisely to make holistic aggregates algebraic again. HyperLogLog gives COUNT(DISTINCT) a fixed-size mergeable state (which is why approx_count_distinct is dramatically cheaper), and t-digest / quantile sketches do the same for percentiles. Whenever an aggregate is unexpectedly slow, ask whether it is holistic — and if it is, whether an approximate version with a stated error bound is acceptable.
GROUP BY is a grain change. You start with a set of rows at one grain — one row per order line — and you end with a set at a coarser grain — one row per customer. Every column in the output must be either part of the new grain (in the GROUP BY) or a function that collapses many values into one (an aggregate).
That is the entire rule, and it explains the error you keep hitting: a bare column in SELECT that is neither grouped nor aggregated has no single defined value at the new grain, so the engine rejects it rather than picking one arbitrarily.
- Output grain = exactly the
GROUP BYcolumns. State it out loud before writing the query; it is the contract of the result. WHEREfilters rows before collapsing,HAVINGfilters groups after. PreferWHERE— it reduces the data before the expensive step.- All aggregates except
COUNT(*)ignore NULLs. Decide deliberately whether NULL means zero or means unknown, and useCOALESCEto say so explicitly. - An aggregate over zero rows returns NULL (except
COUNT, which returns 0). A group with no rows produces no row at all — absence and zero are different, and dashboards conflate them.
Fire this model the moment you see: "column must appear in the GROUP BY clause" · an average that disagrees with sum/count · a metric that jumped when a new nullable column appeared · missing rows in a time series (no rows to group, so no zero) · a COUNT(DISTINCT) dominating query runtime.
A dashboard needs distinct-user counts over arbitrary time ranges on a very large event table. Exact count, approximate sketch, or pre-aggregation?
The trap worth naming explicitly is summing daily distinct counts to get a monthly figure. It is wrong, it is a very common bug, and it always overcounts — because a user active on ten days is counted ten times. The correct pattern is to store per-bucket sketches, which merge properly, rather than per-bucket counts, which do not.
The general rule: know which of your metrics are additive across dimensions and which are not. Sums and counts roll up freely; distinct counts, ratios and percentiles do not. That single classification prevents most of the metric errors that reach a dashboard.
(c) Hands-on · 25 min
Save as agg_lab.py, run python3 agg_lab.py.
"""agg_lab.py — GROUP BY, HAVING, subqueries, and the classic traps."""
from __future__ import annotations
import sqlite3
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.executescript("""
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
dept TEXT NOT NULL,
salary INTEGER, -- nullable on purpose
hired TEXT
);
INSERT INTO employees VALUES
(1,'Alice','Eng', 120000,'2020-03-01'),
(2,'Bob', 'Eng', 95000,'2021-06-15'),
(3,'Carol','HR', 80000,'2019-01-10'),
(4,'Dan', 'Eng', 140000,'2018-09-20'),
(5,'Eve', 'Sales', 110000,'2022-02-01'),
(6,'Frank','Sales', 60000,'2023-11-05'),
(7,'Grace','HR', NULL,'2024-05-12');
""")
def run(label: str, sql: str) -> None:
print(f"\n-- {label} --")
for row in cur.execute(sql):
print(" ", row)
# ---- 1. Basic GROUP BY with multiple aggregates ----
run("Headcount, non-null salary count, and avg per dept", """
SELECT dept,
COUNT(*) AS headcount,
COUNT(salary) AS with_salary,
SUM(salary) AS total_pay,
AVG(salary) AS avg_pay,
MIN(salary) AS min_pay,
MAX(salary) AS max_pay
FROM employees
GROUP BY dept
ORDER BY avg_pay DESC NULLS LAST;
""")
# ---- 2. HAVING — filters groups after aggregation ----
run("Depts with avg salary > 90k", """
SELECT dept, AVG(salary) AS avg_pay
FROM employees GROUP BY dept
HAVING AVG(salary) > 90000;
""")
# ---- 3. WHERE vs HAVING — filter before AND after ----
run("Filter rows first, then groups: high earners only, dept with >=2 remaining", """
SELECT dept, COUNT(*) AS n_high_earners
FROM employees
WHERE salary > 70000 -- row filter (WHERE)
GROUP BY dept
HAVING COUNT(*) >= 2; -- group filter (HAVING)
""")
# ---- 4. Scalar subquery — above the company average ----
run("Employees earning above company average", """
SELECT name, salary,
(SELECT ROUND(AVG(salary), 0) FROM employees) AS company_avg
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees)
ORDER BY salary DESC;
""")
# ---- 5. Correlated subquery — top earner per dept ----
run("Top earner per dept (correlated subquery)", """
SELECT e.name, e.dept, e.salary
FROM employees e
WHERE e.salary = (
SELECT MAX(salary) FROM employees WHERE dept = e.dept
)
ORDER BY e.dept;
""")
# ---- 6. IN subquery ----
run("Depts that have anyone earning over 100k", """
SELECT DISTINCT dept
FROM employees
WHERE dept IN (
SELECT dept FROM employees WHERE salary > 100000
);
""")
# ---- 7. COUNT(DISTINCT) — exact but expensive ----
run("Number of distinct salary values", """
SELECT COUNT(DISTINCT salary) AS n_distinct_salaries FROM employees;
""")
# ---- 8. STRING_AGG / GROUP_CONCAT ----
run("Comma-joined names per dept", """
SELECT dept, GROUP_CONCAT(name, ', ') AS names
FROM employees GROUP BY dept ORDER BY dept;
""")
# ---- 9. Percentile (median) ----
try:
run("Median salary per dept (Postgres syntax; may fail in SQLite)", """
SELECT dept, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median
FROM employees GROUP BY dept;
""")
except sqlite3.OperationalError as e:
print(" SQLite doesn't support PERCENTILE_CONT:", e)
# Fallback: use AVG of MIN and MAX for a very rough proxy
run("Rough median substitute (min+max)/2", """
SELECT dept, (MIN(salary) + MAX(salary)) / 2.0 AS rough_median
FROM employees WHERE salary IS NOT NULL GROUP BY dept;
""")
# ---- 10. TRAP: SUM over empty set is NULL, not 0 ----
run("SUM of no matching rows → NULL", """
SELECT SUM(salary) FROM employees WHERE dept = 'Legal';
""")
run("Fix: COALESCE(SUM(x), 0)", """
SELECT COALESCE(SUM(salary), 0) FROM employees WHERE dept = 'Legal';
""")
# ---- 11. TRAP: ‘average of averages’ ----
run("WRONG: average of per-dept averages (not the true company average)", """
SELECT AVG(avg_pay) AS avg_of_avgs FROM (
SELECT AVG(salary) AS avg_pay FROM employees GROUP BY dept
);
""")
run("RIGHT: single-pass overall average", """
SELECT AVG(salary) AS true_company_avg FROM employees;
""")
# ---- 12. Strict-mode violation — will error in Postgres, silent in SQLite ----
run("SQLite lets this pass; Postgres rejects (name not in GROUP BY / aggregate)", """
SELECT dept, name, AVG(salary) FROM employees GROUP BY dept;
""")
conn.close()Anatomy of the script
Extend the schema:
CREATE TABLE sales (
id INTEGER PRIMARY KEY,
salesperson_id INTEGER REFERENCES employees(id),
amount INTEGER NOT NULL,
sold_at TEXT NOT NULL
);
INSERT INTO sales VALUES
(1, 5, 5000, '2026-01-15'), (2, 5, 3000, '2026-01-22'),
(3, 6, 1000, '2026-02-05'), (4, 5, 4000, '2026-02-20'),
(5, 6, 6000, '2026-03-01'), (6, 5, 7000, '2026-03-10');Write:
SELECT e.dept,
strftime('%Y-%m', s.sold_at) AS month,
COUNT(*) AS deals,
SUM(s.amount) AS revenue
FROM sales s
JOIN employees e ON e.id = s.salesperson_id
GROUP BY e.dept, month
ORDER BY month, e.dept;Now try adding HAVING SUM(amount) > 5000 — see which (dept, month) pairs qualify. And notice: the salespeople-per-dept join here is 1-to-many but each sale has ONE salesperson, so no multiplication.
(d) Production reality · 15 min
The DAU query was `SELECT COUNT(DISTINCT user_id) FROM events WHERE event_date = today`. On a 5 TB events table this had to hash-partition all events, spill to disk, and shuffle across 40 Redshift nodes. Four hours per run.
Switched to `APPROX_COUNT_DISTINCT(user_id)` — HyperLogLog under the hood. Constant memory, 30-second runtime, ~2 % error (well within business tolerance). For historical rollups, precompute HLL sketches per hour and MERGE them for any time window.
Weekly dashboard computed per-region average order value with a per-region GROUP BY, then averaged those regional averages to display global AOV. Regions ranged from 100 orders (Australia) to 500,000 orders (US). Averaging the averages gave equal weight to Australia and the US.
Aggregate from the row-level fact: `SUM(order_value) / COUNT(*)` across all orders. Or, weighted average of the per-region AOVs using COUNT as the weight. Either works; the ‘naive avg of avgs’ does not.
Report: revenue per product per week. Query: `SUM(amount) FROM sales WHERE product_id = ? AND week = ?` called per (product, week) grid cell. Products with no sales in a week returned `NULL`, not 0. The dashboard rendered NULL as a blank cell, so entire product rows appeared to have gone missing.
Wrap every SUM in `COALESCE(SUM(amount), 0)`. Better: build the (product × week) grid via a CROSS JOIN of dimensions, then LEFT JOIN the sales aggregate — guarantees a row exists for every combination.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- What is aggregation, in one sentence? (mention buckets + summary)
- What's the difference between WHERE and HAVING? (which runs first)
- What returns NULL that most people expect to return 0? (and how to fix it)
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.