SQL Fundamentals · Aggregation and Grouping

GROUP BY, HAVING and Conditional Aggregation

One row per group, filters before and after grouping, counting subsets with FILTER, the fan-out trap, and subtotals with ROLLUP.

  • 10 min read
  • 13 practice questions
  • 3 graded problems
On this page
  1. Description
  2. One row per group
  3. WHERE filters rows, HAVING filters groups
  4. Aggregates and NULL
  5. Conditional aggregation
  6. Fan-out: when joining multiplies your numbers
  7. Subtotals with ROLLUP
  8. Which tool for which question?
  9. Where it breaks
  10. Tradeoffs
  11. Check yourself
  12. Key takeaways
  13. Quick revise
  14. Practice

Description

So far every query has looked at rows one at a time. Reports rarely want that: they want one line per something - revenue per country, orders per customer, a cancellation rate per month. That's what GROUP BY does. It sorts rows into groups and boils each group down to a few numbers with aggregates such as COUNT, SUM and AVG.

It's easy to start with, and it's also where reporting numbers quietly go wrong. Three classic examples from a finance dashboard's first week:

  • Someone adds "only countries over $100" as WHERE SUM(total) > 100, and Postgres refuses to run it.
  • Revenue jumps 40% overnight after a join to order_items - no new sales, the same orders just counted once per line item.
  • The average order value creeps up because a new column is NULL for half the rows, and AVG skips NULLs instead of treating them as zero.

The last two run without any error. By the end of this lesson you'll spot all three on sight. We'll go from simple to more advanced:

  1. One row per group, and what you're allowed to SELECT after grouping.
  2. WHERE versus HAVING - filtering rows versus filtering groups.
  3. How aggregates treat NULL.
  4. Several counts in one pass with FILTER, and rates that don't round to zero.
  5. Fan-out: why a join can inflate your totals.
  6. Subtotals and grand totals with ROLLUP.

All examples below use one small dataset: five customers (with a country), eight orders (status, total; order 108 is a guest checkout with no customer), and order_items (quantity, unit_price per product line). Every result shown was produced by PostgreSQL 18.

One row per group

GROUP BY collapses all rows that share the grouping columns into one output row. After it runs, each row stands for a group, so everything in SELECT must be one value per group: a grouping column, or an aggregate (COUNT, SUM, AVG, MIN, MAX, string_agg, ...).

SELECT c.country, COUNT(*) AS orders, SUM(o.total) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
GROUP BY c.country
ORDER BY revenue DESC, c.country;
countryordersrevenue
DE2100.00
US2100.00
IE130.00
The five paid orders with a customer, sorted into one group per country, and the single row each group becomes.

Selecting o.total here would be an error - which of a country's totals would it show? One exception trips people up in reviews: Postgres lets you select any column of a table whose primary key you grouped by (GROUP BY c.id makes c.name legal), because it's functionally dependent - one id, one name. Grouping by c.id, c.name is the portable way to say the same thing.

Note the ORDER BY revenue DESC, c.country: two countries tie at 100.00, and without the second key their order is whatever the plan happens to produce.

WHERE filters rows, HAVING filters groups

The logical order is FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. WHERE runs before any group exists, so it can't use an aggregate - that is why WHERE SUM(total) > 100 is an error. A condition on the group goes in HAVING:

SELECT c.country, SUM(o.total) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
GROUP BY c.country
HAVING SUM(o.total) >= 100
ORDER BY c.country;
countryrevenue
DE100.00
US100.00

Put each condition where it belongs: a condition on rows (status = 'paid') in WHERE, so those rows never reach the grouping; a condition on groups (SUM(...) >= 100) in HAVING. HAVING status = 'paid' would be an error - status isn't a single value per group - and a row condition moved into HAVING where it's legal (a grouping column) still means grouping rows you're about to throw away.

Aggregates and NULL

Aggregates skip NULLs, and that has three consequences worth memorizing:

  • COUNT(*) counts rows; COUNT(col) counts rows where col is not NULL. After a LEFT JOIN, a customer with no match still gets one row with NULLs - COUNT(*) says 1, COUNT(o.id) says 0.
  • SUM, AVG, MIN, MAX over no values (or only NULLs) return NULL, not 0. Wrap in COALESCE(SUM(x), 0) when the report needs a zero.
  • AVG(x) divides by the number of non-NULL values. If NULL means "unknown", that's right; if it means "zero", say so with AVG(COALESCE(x, 0)).
SELECT c.name,
       COUNT(*)    AS count_star,
       COUNT(o.id) AS paid_orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
GROUP BY c.id, c.name
ORDER BY c.id;
namecount_starpaid_orders
Ava Thompson22
Liam O'Brien11
Sofia Rossi10
Noah Fischer22
Emma Clarke10

Sofia and Emma have no paid orders, yet COUNT(*) says 1 - it's counting the NULL-extended row the outer join created. Note too that the status condition sits in the join's ON: in WHERE it would delete those NULL rows and quietly turn the outer join back into an inner one.

Conditional aggregation

Often you want several counts per group in one pass - paid versus lost orders per customer. A WHERE can't do it (it would remove the rows the other count needs). Attach the condition to the aggregate instead:

SELECT c.name,
       COUNT(*) FILTER (WHERE o.status = 'paid')                      AS paid,
       COUNT(*) FILTER (WHERE o.status IN ('cancelled', 'refunded'))  AS lost,
       SUM(o.total) FILTER (WHERE o.status = 'paid')                  AS paid_revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;
namepaidlostpaid_revenue
Ava Thompson21100.00
Liam O'Brien1030.00
Sofia Rossi01NULL
Noah Fischer20100.00

FILTER (WHERE ...) is standard SQL and supported by Postgres; the portable spelling in older engines is SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END). Two traps: forget the ELSE 0 and a group with no matches sums to NULL; and COUNT(*) FILTER of nothing is 0 while SUM(...) FILTER of nothing is NULL (Sofia's paid_revenue).

Rates follow the same pattern - and bring an integer trap: COUNT(...) / COUNT(*) divides two integers and truncates, so 1 of 3 is 0. Multiply by 100.0 (a numeric) first: ROUND(100.0 * COUNT(*) FILTER (WHERE ...) / COUNT(*), 1).

Fan-out: when joining multiplies your numbers

A join to a one-to-many table repeats the "one" side once per match. Aggregating a column of the "one" side after that join counts it again for every child row:

SELECT COUNT(*) AS joined_rows, SUM(o.total) AS summed_totals
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'paid';
joined_rowssummed_totals
7325.00

The five paid orders with items are worth 230.00, but orders 101 and 106 have two lines each, so their totals were added twice. Fix it by aggregating at the level you joined to (sum the items), counting the parent with COUNT(DISTINCT ...), or aggregating each child table in its own subquery before joining:

SELECT SUM(i.quantity * i.unit_price) AS revenue, COUNT(DISTINCT o.id) AS orders
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'paid';
revenueorders
230.005

Fan-out is the most expensive bug in reporting SQL because the numbers are plausible - just too big - and a single-item test dataset hides it completely. Joining two child tables of the same parent (items and payments) multiplies them together; aggregate each one first.

Subtotals with ROLLUP

GROUP BY ROLLUP (a, b) produces the groups for (a, b), then subtotals for each a, then a grand total - the rolled-up columns are NULL in the subtotal rows. GROUPING(col) returns 1 on those rows, which tells a subtotal's NULL apart from a real NULL in the data:

SELECT COALESCE(c.country, 'All countries') AS country, SUM(o.total) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
GROUP BY ROLLUP (c.country)
ORDER BY GROUPING(c.country), c.country;
countryrevenue
DE100.00
IE30.00
US100.00
All countries230.00

GROUPING SETS ((a, b), (a), ()) lists exactly the groupings you want, and CUBE (a, b) produces every combination. Order by GROUPING(...) to put the totals last.

Which tool for which question?

You wantUseWhy not the alternatives
Drop rows before counting (only paid orders)WHEREHAVING would group rows you then discard
Keep only some groups (countries over $100)HAVINGWHERE runs before groups exist
Several counts side by side (paid and lost per customer)COUNT(*) FILTER (WHERE ...)A WHERE would remove the rows the other counts need
A total next to every row, without collapsing rowsA window function: SUM(total) OVER (PARTITION BY ...)GROUP BY returns one row per group, losing the detail
Subtotals and a grand totalGROUP BY ROLLUP (...)A UNION ALL of several queries scans once per level
Distinct values, no aggregateSELECT DISTINCT or GROUP BYSame plan in Postgres; DISTINCT says what you mean

Where it breaks

  • Aggregates in WHERE - an error. Move the condition to HAVING.
  • Status or date filters in HAVING instead of WHERE - legal only for grouped columns, and it groups rows you then discard.
  • COUNT(*) after an outer join - counts customers with no match as 1. Count a column from the joined table.
  • SUM of nothing is NULL - reports show blanks where the business expects 0. COALESCE.
  • Integer division in rates - 7 / 9 is 0. Use 100.0 *, or cast.
  • Fan-out - joining a child table multiplies the parent's numbers. Aggregate at one level per query, or pre-aggregate.
  • Ties in ORDER BY - two equal revenues come back in an order that can change between runs; add a tiebreaker.

Tradeoffs

  • FILTER is clearer than CASE inside an aggregate, and says what you mean; CASE is the version that runs everywhere.
  • COUNT(DISTINCT x) fixes a fan-out count, but on large joins it's expensive (it has to deduplicate); pre-aggregating each child table and joining the results is usually faster and makes the grain obvious.
  • ROLLUP gives totals in one query and one scan; a UNION ALL of separate queries is easier to read but scans again for every level.

Going further. The deep dive (next tab) covers how the database actually computes a GROUP BY - hashing or sorting, and what happens when the groups don't fit in memory - why COUNT(DISTINCT) is the expensive one, what aggregates do to number types, and how dashboards aggregate billions of rows without scanning them every time.

Check yourself

  1. Why does WHERE COUNT(*) > 5 fail, and where does the condition go?
Show answer

WHERE runs before rows are grouped, so there's no group to count yet. Group conditions go in HAVING COUNT(*) > 5.

  1. After customers LEFT JOIN orders, a customer has no orders. What do COUNT(*) and COUNT(o.id) return for them?
Show answer

1 and 0. The outer join produces one row with NULLs in the order columns; COUNT(*) counts that row, COUNT(o.id) skips the NULL.

  1. Revenue went up after you joined order_items to add a "units sold" column. What happened, and name two fixes.
Show answer

Fan-out: each order now appears once per line item, so SUM(o.total) adds multi-item orders several times. Sum the items (SUM(quantity * unit_price)) instead, count orders with COUNT(DISTINCT o.id), or aggregate items per order in a subquery before joining.

Key takeaways

  • After GROUP BY, every selected value must be one per group: a grouping column or an aggregate.
  • WHERE filters rows before grouping; HAVING filters groups after it.
  • Aggregates skip NULLs: COUNT(col) ≠ COUNT(*), SUM of nothing is NULL, AVG ignores NULLs.
  • COUNT(*) FILTER (WHERE ...) computes several conditional aggregates in one pass.
  • Joining a one-to-many table multiplies the parent's rows - aggregate at one grain, or pre-aggregate.
  • ROLLUP / GROUPING SETS add subtotals; GROUPING() tells a subtotal apart from real NULLs.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You wantUseNot
Drop rows before counting (only paid orders)WHEREHAVING - it groups rows you then throw away
Keep only some groups (countries over $100)HAVING SUM(...) > 100WHERE - groups don't exist yet
Several counts side by sideCOUNT(*) FILTER (WHERE ...)A WHERE - it removes rows the other counts need
A group total next to every rowSUM(x) OVER (PARTITION BY ...)GROUP BY - it collapses the rows
Subtotals and a grand totalGROUP BY ROLLUP (...)UNION ALL of several queries - one scan per level
Distinct values, no aggregateSELECT DISTINCT- (same plan as GROUP BY in Postgres)

After GROUP BY, every selected value must be one per group: a grouping column or an aggregate. (Postgres also allows any column of a table whose primary key you grouped by.)

Aggregates and NULL

ExpressionResult
COUNT(*)Rows, NULL or not
COUNT(col)Rows where col is not NULL - use it after a LEFT JOIN
SUM / AVG / MIN / MAX of no rowsNULL - wrap in COALESCE(..., 0)
AVG(col)Divides by the non-NULL count; AVG(COALESCE(col, 0)) if NULL means zero
COUNT(*) FILTER (WHERE ...) of nothing0
SUM(...) FILTER (WHERE ...) of nothingNULL

Snippets worth knowing by heart

-- several counts in one pass
SELECT customer_id,
       COUNT(*) FILTER (WHERE status = 'paid')                     AS paid,
       COUNT(*) FILTER (WHERE status IN ('cancelled', 'refunded')) AS lost
FROM orders GROUP BY customer_id;

-- a rate that doesn't truncate to 0
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'cancelled') / COUNT(*), 1)

-- portable conditional sum (older engines)
SUM(CASE WHEN status = 'paid' THEN total ELSE 0 END)

-- revenue without fan-out: sum at the grain you joined to
SELECT SUM(i.quantity * i.unit_price) AS revenue, COUNT(DISTINCT o.id) AS orders
FROM orders o JOIN order_items i ON i.order_id = o.id;

-- subtotals, totals last
GROUP BY ROLLUP (country) ORDER BY GROUPING(country), country

The traps

  • An aggregate in WHERE is an error. It goes in HAVING.
  • COUNT(*) after an outer join counts a customer with nothing as 1.
  • SUM of nothing is NULL, not 0.
  • COUNT(...) / COUNT(*) is integer division: 1 of 3 is 0. Multiply by 100.0 first.
  • CASE without ELSE 0 inside SUM gives NULL for a group with no matches.
  • Fan-out: joining a one-to-many table repeats the parent, so SUM(o.total) counts multi-line orders several times. Two child tables multiply each other.
  • A filter on the right table of a LEFT JOIN belongs in ON; in WHERE it turns the join back into an inner join.
  • Ties in ORDER BY come back in any order - add a tiebreaker.
  • ROLLUP's subtotal rows have NULL in the rolled-up column; tell them from real NULLs with GROUPING(col).

Staff-level points, one line each

  • Two strategies: HashAggregate (one hash entry per group, no output order) and GroupAggregate (sorted input, almost no memory).
  • A hash aggregate that outgrows work_mem × hash_mem_multiplier spills to disk in batches (Batches: 177 ... Disk Usage in EXPLAIN ANALYZE, PostgreSQL 13+). An index on the group key lets the planner stream a GroupAggregate instead.
  • For GROUP BY a, b the planner multiplies the columns' distinct counts; correlated columns need CREATE STATISTICS ... (ndistinct).
  • COUNT(DISTINCT x) sorts on the group key plus x, and can't be combined across parallel workers - no Partial aggregate.
  • Distinct counts, medians and percentiles don't roll up: daily distinct users summed ≠ monthly distinct users. HyperLogLog sketches merge (BigQuery, Redshift, Snowflake, ClickHouse built in; postgresql-hll for Postgres).
  • sum(int) is bigint, sum(bigint) is numeric, avg(int) is numeric.
  • Float sums depend on the order rows are added, so a parallel plan can change the last digits between runs. Money is numeric or integer cents.
  • count(*) on a big table is a full scan (MVCC has no single true count). For "about how many", read pg_class.reltuples.
  • Don't re-aggregate raw rows on every page view: a materialized view (REFRESH ... CONCURRENTLY needs a unique index), rollup tables, sharded counters (one hot counter row serializes writers), or a columnar engine.
  • Every metric has a grain (an order, an order line, a visit). Two dashboards disagree when they aggregate at different grains.

Say it in an interview

  • "That's fan-out - I'll aggregate the items per order first, then join."
  • "The hash aggregate is spilling; I'd raise work_mem for this session only, or give it an index to stream the groups."
  • "Distinct users don't roll up from daily numbers; either recompute per period or store mergeable sketches."
  • "I'd keep money in numeric - a float sum can differ between two runs of the same query."

Practice

This skill comes with 13 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice GROUP BY, HAVING and Conditional Aggregation free

Revenue by Country

EasyFreeSQL problem

The expansion team wants to know which countries are worth a local-language site. Revenue counts only orders that went through - status paid, shipped or delivered; pending, cancelled and refunded orders don't count.

Cancellation Rate per Customer

MediumProSQL problem

Fraud and operations want to spot customers who cancel a lot. For every customer with at least 3 orders (any status), return:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Country Sales Without Double Counting

HardProSQL problem

The board pack needs one line per country. Consider only orders with status paid, shipped or delivered. For every country with at least one such order, return:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice GROUP BY, HAVING and Conditional Aggregation free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime