Search Tech Journey

Find topics, journeys and posts

6-month learning plan38 / 130
back to blog
data engineeringbeginner 55m read

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).

🗃️DatabasesM04 · Databases & SQL· Session 038 of 130 90 min

🎯 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.

You will be able to
  • 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



(a) Intuition · 5 min

A library's daily foot-traffic log vs the monthly report
🌍 Real world

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.

💻 Code world

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.

The aggregate function starter pack
  • 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.
  1. 1974
    SEQUEL includes GROUP BY
    In the original System R paper. Aggregate + grouping is core SQL from day one.
  2. 1992
    SQL-92 formalises HAVING
    Standardises post-aggregation filtering. Also introduces standard join syntax.
  3. 1999
    SQL:1999 adds GROUPING SETS / CUBE / ROLLUP
    Multi-dimensional aggregation in one query. The foundation of OLAP cubes.
  4. 2003
    PERCENTILE_CONT / PERCENTILE_DISC
    True median and p95 without dirty hacks. Every modern engine supports these.
  5. 2007
    Flajolet's HyperLogLog paper
    APPROX_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)

idnamedeptsalary
1AliceEng120000
2BobEng95000
3CarolHR80000
4DanEng140000
5EveSales110000
6FrankSales60000
7GraceHRNULL

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:

deptheadcountavg_salary
Eng3118333
Sales285000
HR280000

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

WHERE

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')`
HAVING

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

Scalar subquery — returns one value
Usable anywhere a value is expected. `SELECT name FROM employees WHERE salary > (SELECT AVG(salary) FROM employees);` — evaluated once.
scalar
IN / EXISTS subquery — returns a list
Tested against a column. `WHERE dept IN (SELECT dept FROM depts WHERE region='India')`. NOT EXISTS is your anti-join.
list
Correlated subquery — references the outer row
Inner query runs once per outer row: `WHERE salary = (SELECT MAX(salary) FROM employees WHERE dept = e.dept)`. Convenient; often slow. Replace with window functions (S039).
correlated
Derived table — a whole result set as a table
`FROM (SELECT ... GROUP BY dept) d JOIN ...`. The workhorse. CTEs (S040) are the cleaner form.
derived

The Big Three aggregation bugs

1clause
1. WHERE / HAVING swap

Filtering rows when you meant groups (or vice versa). Symptom: totally wrong count.

2null
2. SUM over empty = NULL

Not zero. Dashboards silently show blank cells. Wrap every SUM/COUNT that might be empty in COALESCE(x, 0).

3math
3. Average of averages

Averaging per-region AOV then averaging those = wrong global AOV when regions have different order counts. Always aggregate from the row-level fact.


Common misconception
✗ What most people think

"COUNT(*) and COUNT(column) are the same thing — they both count the rows."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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;    -- 0
From first principles
Start with the question

Why can SUM, COUNT, MIN and MAX be computed in a distributed system with almost no data movement, while COUNT(DISTINCT ...) and MEDIAN cannot?

  1. 1
    In 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
  2. 2
    That 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. 3
    SUM and COUNT qualify with a single number as state; AVG qualifies with the pair (sum, count); MIN/MAX qualify with one value.
    forced by · each has an associative, commutative merge over a constant-size state
  4. 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
  5. 5
    Median 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

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.

Mental modelCollapse to the grain

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 BY columns. State it out loud before writing the query; it is the contract of the result.
  • WHERE filters rows before collapsing, HAVING filters groups after. Prefer WHERE — it reduces the data before the expensive step.
  • All aggregates except COUNT(*) ignore NULLs. Decide deliberately whether NULL means zero or means unknown, and use COALESCE to 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.
🔔 Fires when you see

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.

The tradeoff

A dashboard needs distinct-user counts over arbitrary time ranges on a very large event table. Exact count, approximate sketch, or pre-aggregation?

Exact COUNT(DISTINCT) at query time
+ you gain unarguably correct, no extra pipeline, and any new dimension or filter works immediately with no prep
− you pay holistic, so it requires shuffling the distinct values themselves; cost and memory scale with cardinality, and it is frequently the single most expensive operation in a warehouse workload
pick when cardinality is modest or the query is rare — an audit, a reconciliation, a monthly finance number that must tie exactly
Approximate (HyperLogLog / approx_count_distinct)
+ you gain fixed small state per group regardless of cardinality, mergeable across partitions and across time buckets, dramatically cheaper and with a known error bound rather than an unknown one
− you pay the answer is wrong by a small percentage, which is unacceptable for anything financial or regulatory, and stakeholders will notice the number changing slightly between runs
pick when the metric drives a trend or a decision rather than a payment — product analytics, capacity planning, engagement dashboards
Pre-aggregate into a rollup table
+ you gain query time drops to a scan of a tiny table; cost is paid once at write time and amortised over every dashboard load
− you pay distinct counts are not additive, so daily rollups cannot be summed into a monthly number — you must store mergeable sketches per bucket, or recompute per range
pick when the set of dimensions and time grains is known and stable, and read volume is high — the standard warehouse answer
What a senior engineer actually does

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

Query #1 — `COUNT(*)` vs `COUNT(salary)`
HR has `COUNT(*)=2` but `COUNT(salary)=1` because Grace's NULL is skipped by `COUNT(col)`. Same subtlety applies to SUM and AVG.
null
Query #3 — WHERE and HAVING together
WHERE filters rows first (drops Frank at 60k and Grace with NULL), THEN GROUP BY buckets, THEN HAVING drops groups with fewer than 2 rows. Order matters.
order
Query #5 — correlated subquery
Runs the inner MAX query once per outer row (7 times here). On a 10M-row employees table this becomes 10M MAX queries — the moment to switch to a window function.
expensive
Query #10 — SUM over empty
The most common dashboard bug. Blank cells where zeros should appear. `COALESCE(SUM(x), 0)` is a habit, not an afterthought.
trap
Query #11 — average of averages
Off by 1 – 30 % in realistic data. If a manager asks ‘why does our per-region avg not match the global avg?’ this is why. Always aggregate from row-level facts.
math
Query #12 — strict-mode violation
SQLite happily returns SOMEONE's name per dept — silently wrong. Postgres errors out. This is why prod-parity local DBs matter.
portability
Try itCompute monthly revenue per department

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.

💡 Hint · You'll combine JOIN + GROUP BY on two columns.

(d) Production reality · 15 min

War story A social network · daily active users4-hour report → 30 seconds after switching to HLL
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Exact `COUNT(DISTINCT)` on billion-row tables is a code smell. Every warehouse (BigQuery, Snowflake, Redshift, Presto) ships an APPROX_COUNT_DISTINCT function. Use it unless finance needs a to-the-penny number.
Post-mortem
War story A marketing analytics teamGlobal AOV reported off by 15 % for 6 months
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
You cannot re-average an aggregate directly unless every bucket had the same weight. In production this is almost never true. Always drill back to the row-level fact table, or carry both SUM and COUNT so downstream can re-weight.
War story A payments finance team · SUM over emptyWeekly report showed blank cells for entire product lines
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
The DB tells you ‘no rows matched’ by returning NULL from an aggregate. The UI often renders NULL indistinguishably from ‘missing data’. Bridge the gap in SQL with COALESCE and dimension grids so ‘zero’ and ‘missing’ never look the same.

Where this shows up in the rest of the plan

Aggregation is the backbone of every analytics workload
S039 · Window functions
The workaround for correlated subqueries. Aggregate WITHIN a window without collapsing rows.
S040 · CTEs
The right shape for ‘aggregate first, then join’ — kills the multiplication bug.
S041 · Indexes
GROUP BY on an indexed column can avoid a sort.
S052 · Star schemas
Fact tables are aggregated over dimension keys. This is what warehouses are built for.
S073 · Sampling & sketches
HLL, t-digest, count-min sketch — probabilistic alternatives when exact aggregates are too expensive.
S090 · Monitoring
Every p50/p95/p99 metric is a PERCENTILE_CONT aggregate.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

  1. What is aggregation, in one sentence? (mention buckets + summary)
  2. What's the difference between WHERE and HAVING? (which runs first)
  3. 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.