Tags: web-dev analytics concept
Aggregation and Grouping
Date: 2026-09-27
GROUP BYcollapses rows into one per group, and every column you select must then be either a group key or an aggregate. Most SQL errors and most wrong numbers come from not knowing the order the clauses actually run in — which isn’t the order they’re written.
Aggregation reduces many rows to one value — COUNT, SUM, AVG, MIN, MAX. Grouping decides which rows get reduced together: one output row per distinct combination of the GROUP BY columns.
The collapse
events SELECT channel, COUNT(*) AS events,
COUNT(DISTINCT user_id) AS users
user_id channel event FROM events GROUP BY channel
u1 email page_view
u1 email purchase channel events users
u2 email page_view email 3 2
u3 paid page_view paid 2 1
u3 paid page_view
Every output row must have one value per column. channel is fine — it’s the key. event isn’t: the email group holds page_view and purchase, and there’s no single answer. That’s the error “must appear in the GROUP BY clause or be used in an aggregate function”.
The order it really runs in
written executed
SELECT 5 1 FROM / JOIN build the rows
FROM 1 2 WHERE filter rows
WHERE 2 3 GROUP BY collapse into groups
GROUP BY 3 4 HAVING filter groups
HAVING 4 5 SELECT compute outputs, window functions
ORDER BY 6 6 ORDER BY sort
LIMIT 7 7 LIMIT cut
This one table explains most errors:
WHERE COUNT(*) > 5fails — there are no groups yet at step 2. Filtering on an aggregate isHAVING- An alias from
SELECTcan’t be used inWHERE, becauseSELECThasn’t run. Several dialects relax this forGROUP BYandORDER BY— SQL Dialects - A window function can’t be filtered in
WHEREorHAVING, because it’s computed at step 5 — Window Functions - Joins run first, so a fan-out join inflates every aggregate after it — Joins
WHERE vs HAVING on the same question:
-- customers with 3+ orders, counting only paid orders
SELECT customer_id, COUNT(*) AS paid_orders
FROM orders
WHERE status = 'paid' -- row filter: which orders count
GROUP BY customer_id
HAVING COUNT(*) >= 3; -- group filter: which customers surviveMoving status = 'paid' into HAVING wouldn’t work — status doesn’t exist per group. Moving the count into WHERE wouldn’t either. Put a row filter in WHERE whenever you can; it shrinks the data before the expensive step.
How the aggregates treat NULL
refund_amount: 10, NULL, 30, NULL
COUNT(*) 4 counts rows
COUNT(refund_amount) 2 counts non-NULL values
SUM(refund_amount) 40
AVG(refund_amount) 20 40 / 2 — NULLs left out of the divisor too
SUM over zero rows NULL not 0
AVG silently ignores nulls, so “average refund” here is the average of refunded orders (20), not across all orders (10). Which one you meant is a metric definition question — Metric Design. COALESCE(x, 0) inside the aggregate turns it into the other one.
SUM over no rows is NULL, which then turns any arithmetic it touches into NULL. Wrap the result in COALESCE(..., 0) where zero is the right answer.
COUNT(DISTINCT) doesn’t add up
users by day users that week
Mon 2 (u1, u2)
Tue 2 (u1, u3) 3 (u1, u2, u3) ← not 4
Distinct counts aren’t additive. Summing daily users double-counts anyone who came twice, so weekly users must be recomputed from rows, never summed from daily totals. The same applies across segments, channels and devices. It’s why pre-aggregated tables can’t answer “unique users” for an arbitrary range — Event Streams vs Aggregates.
At warehouse scale, exact distinct counts are expensive. APPROX_COUNT_DISTINCT (HyperLogLog, a probabilistic sketch) trades roughly a percent of accuracy for a large speed-up — fine for a dashboard, wrong for anything reconciled to finance. [CHECK: the error bound per engine — it varies by implementation and configuration.]
Beyond one level of grouping
GROUP BY ROLLUP(channel, device)adds subtotal rows per channel and a grand total, withNULLin the rolled-up column. Distinguish those from real nulls withGROUPING()- Conditional aggregation — several filtered counts in one pass, the pivot every funnel query needs:
SELECT channel,
COUNT(DISTINCT CASE WHEN event = 'page_view' THEN user_id END) AS viewed,
COUNT(DISTINCT CASE WHEN event = 'add_to_cart' THEN user_id END) AS carted,
COUNT(DISTINCT CASE WHEN event = 'purchase' THEN user_id END) AS bought
FROM events
GROUP BY channel;CASE with no ELSE yields NULL, which COUNT skips — that’s the trick. Postgres also offers COUNT(*) FILTER (WHERE ...) for the same thing — Funnel Analysis.
Traps
- Grouping by a timestamp gives one group per instant. Truncate to the day first, in the reporting timezone rather than UTC — Timezones and Date Boundaries
- Averages of averages. Averaging each day’s conversion rate weights a 100-visit Sunday the same as a 10,000-visit Monday. Sum the numerators and denominators, then divide — Simpson’s Paradox
- SQLite, and MySQL with
ONLY_FULL_GROUP_BYswitched off, accept a bare non-grouped column and return a value from an arbitrary row in the group. No error, just a wrong answer — SQL Dialects