The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.
In one sentence:Turn rows into insight.
Raw tables are just facts — one row per event, one per order, one per user. Nobody wants to read a million rows. They want summaries: "how much revenue by month," "top 5 products by sales," "average session length per country." Aggregation is the SQL that converts row-level data into these summaries: COUNT, SUM, AVG, MIN, MAX, STRING_AGG — and the GROUP BY clause that decides "aggregate per what."
Think of GROUP BY as "putting rows into buckets," then applying an aggregate function to each bucket. GROUP BY dept says "one bucket per distinct dept"; AVG(salary) says "for each bucket, average the salary column." The output has one row per bucket — you always lose row-level detail after GROUP BY, and that's the whole point.
HAVING filters after aggregation, WHERE filters before. Swapping them is the most common SQL bug on Earth. Subqueries let you use one query's output as another query's input — perfect for "employees earning above the company average."
The forever gotcha:every column in SELECT must either appear in GROUP BY or be inside an aggregate function. If it doesn't, some databases (MySQL) return whatever value they feel like — a silently wrong report. Postgres and Snowflake reject it outright, which is a feature, not a bug.
Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.
Save as agg_lab.py:
import sqlite3conn = sqlite3.connect(":memory:"); cur = conn.cursor()cur.executescript("""CREATE TABLE employees ( id INTEGER PRIMARY KEY, name TEXT, dept TEXT, salary INTEGER);INSERT INTO employees VALUES (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); -- missing salary""")def run(label, sql): print(f"\n-- {label} --") for row in cur.execute(sql): print(" ", row)run("1. Headcount & avg salary per dept", """ SELECT dept, COUNT(*) AS headcount, COUNT(salary) AS with_salary, AVG(salary) AS avg_sal FROM employees GROUP BY dept ORDER BY avg_sal DESC;""")# Notice HR: COUNT(*)=2 (Carol + Grace), COUNT(salary)=1 (Grace ignored)run("2. HAVING — depts with avg salary > 90k", """ SELECT dept, AVG(salary) AS avg_sal FROM employees GROUP BY dept HAVING AVG(salary) > 90000;""")run("3. WHERE (pre-agg) vs HAVING (post-agg) — filter before grouping", """ SELECT dept, AVG(salary) AS avg_sal FROM employees WHERE salary > 70000 GROUP BY dept HAVING COUNT(*) >= 2;""")run("4. Scalar subquery — earn above company average", """ SELECT name, salary FROM employees WHERE salary > (SELECT AVG(salary) FROM employees);""")run("5. Correlated subquery — top earner per dept", """ SELECT e.name, e.dept, e.salary FROM employees e WHERE e.salary = (SELECT MAX(salary) FROM employees WHERE dept = e.dept);""")run("6. COUNT(DISTINCT) — how many distinct depts", """ SELECT COUNT(DISTINCT dept) AS num_depts FROM employees;""")run("7. STRING_AGG (sqlite: GROUP_CONCAT) — comma list of names per dept", """ SELECT dept, GROUP_CONCAT(name, ', ') AS names FROM employees GROUP BY dept ORDER BY dept;""")# --- CLASSIC TRAP: SUM over empty result is NULL, not 0 ---run("8. SUM of no rows returns NULL, not 0", """ SELECT SUM(salary) FROM employees WHERE dept = 'Legal';""")run("8b. Fix with COALESCE", """ SELECT COALESCE(SUM(salary), 0) FROM employees WHERE dept = 'Legal';""")# --- CLASSIC TRAP: GROUP BY with SELECT of a non-grouped, non-aggregated column ---try: run("9. Illegal in strict SQL — will error in Postgres, silent in old MySQL", """ SELECT dept, name, AVG(salary) FROM employees GROUP BY dept;""")except sqlite3.OperationalError as e: print("Rejected:", e)# SQLite is permissive — it will pick some arbitrary `name` per dept. Try in Postgres, it errors.conn.close()
Checklist — observe when you run:
Query 1: HR shows COUNT(*)=2 but COUNT(salary)=1 and avg_sal=80000 (Grace's null ignored). This is the null-handling subtlety.
Query 2 returns only Eng (AVG=118k); Sales (85k) and HR (80k) filtered out.
Query 3: WHERE salary > 70000 runs first (drops Frank & Grace); HAVING COUNT(*) >= 2 filters groups with at least 2 remaining → only Eng survives.
Query 4: Alice, Dan, Eve — all above the 100.8k average.
Query 5 returns top earner per dept (Dan/Eng, Carol/HR, Eve/Sales). Correlated subquery runs 6 times — expensive on big tables; a window function (S039) does it in one pass.
Query 7: GROUP_CONCAT in SQLite / STRING_AGG in Postgres — one row per dept with comma-separated names.
Query 8: SUM of an empty set = NULL. If your dashboard shows blank instead of 0, this is why. Wrap in COALESCE(..., 0).
Query 9 works in SQLite (returns some arbitrary name per dept) but errors in Postgres — one of many reasons to develop against Postgres, not SQLite, for prod-like behaviour.
Try this modification: add an orders(user_id, amount, order_date) table with a year's worth of dates. Write a query returning revenue per (month, dept-of-buyer). You'll need strftime('%Y-%m', order_date) (SQLite) or DATE_TRUNC('month', ...) (Postgres) as a GROUP BY key.
How this shows up in real systems, gotchas, war stories, common bugs.
Every dashboard you've ever seen is an aggregation query. Revenue by month = SUM GROUP BY DATE_TRUNC('month', …). Active users = COUNT(DISTINCT user_id). p95 latency = PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency). Learn GROUP BY deeply and you can build 80% of any BI tool from raw SQL.
War story — COUNT(DISTINCT) at petabyte scale. A social network computed daily active users as COUNT(DISTINCT user_id) FROM events WHERE event_date = today. On a 5 TB events table this took 4 hours and blew Redshift's memory. Solution: precompute HyperLogLog sketches per hour (APPROX_COUNT_DISTINCT in Redshift/BigQuery/Snowflake). Answer within 2% accuracy, in 30 seconds. Modern lesson: exact COUNT(DISTINCT) on billion-row tables is a code smell — reach for HLL.
War story — the "average of averages" bug. Marketing computed per-region average order value, then averaged those regional averages to get global AOV. The regions had wildly different order counts — average-of-averages doesn't equal true average. Off by 15%. Rule: you cannot aggregate an aggregate that way. Always aggregate from the row-level facts.
War story — the double-counting join. (Same story as S037.) After joining campaigns → emails → clicks, SUM(campaign_cost) was multiplied by (emails × clicks) per campaign. Fix: SUM(campaign_cost) was in the wrong CTE layer. Aggregate before joining fact-to-fact tables. This will re-appear in every SQL bug-fix conversation of your career.
Gotchas top teams handle:
NULL semantics.SUM and AVG skip nulls silently. COUNT(*) counts them. Explicit WHERE col IS NOT NULL and COALESCE in SELECT are your safety net.
Integer division in AVG. In some old MySQL / SQL Server modes, AVG(int_column) returns an integer. Cast to NUMERIC first: AVG(salary::numeric).
GROUP BY 1, 2 (positional). Handy in ad-hoc SQL, but if you reorder SELECT columns you silently change the grouping. Ban in production code; use column names.
HAVING without GROUP BY. Legal — treats the whole table as one group. Rarely what you meant.
Cardinality of the grouping columns matters for performance.GROUP BY user_id on 100M rows produces 100M groups — that's a massive hash table. GROUP BY country produces ~200. Design your dashboards to aggregate on low-cardinality keys first, then drill down.
BigQuery / Snowflake billing.GROUP BY scans full columns. On columnar storage this is cheap. On row-store Postgres, plan indexes carefully.
The senior-engineer tell: they say "aggregate first, join later" and reach for window functions (next session!) whenever they see themselves writing a correlated subquery.
Explain the difference between WHERE and HAVING in one sentence, and give an example that only HAVING can express.
Why does COUNT(*) count more rows than COUNT(salary) on the employees table with Grace?
What does SUM(x) FROM tbl WHERE nothing_matches return, and how do you make it return 0?
Write a query returning depts where at least 2 employees earn more than 90k.
Explain why SELECT dept, name, AVG(salary) FROM employees GROUP BY dept errors in Postgres but silently succeeds in old MySQL. Which behaviour do you prefer and why?
Stretch (connects to S037 Joins): the "top earner per dept" query in this session used a correlated subquery. Rewrite it as a JOIN between employees and a GROUP BY dept, MAX(salary) subquery. Which one do you think is faster on a 10M-row table?