GROUP BY and Aggregation
Collapse many rows into one per group — SUM, COUNT, AVG, and the rules for what can sit beside them in SELECT.
TL;DR
GROUP BY collapses rows that share the same key into a single output row.
SUM, COUNT, AVG, MIN, MAX (and friends) compute summary values
within each group. Together they answer the question every analyst writes a
hundred times: “X per Y” — revenue per customer, errors per service,
predictions per model version.
The one rule that catches every beginner: every column in SELECT must
either appear in GROUP BY or be wrapped in an aggregate. Postgres and
ANSI SQL enforce this strictly. (MySQL historically didn’t, which is why
MySQL queries copied to Postgres so often error with “column must appear
in GROUP BY”.)
A worked example
CREATE TABLE orders (
id int PRIMARY KEY,
user_id int,
country text,
total numeric,
placed date
);
INSERT INTO orders VALUES
(1, 1, 'US', 19.99, '2026-04-01'),
(2, 1, 'US', 42.00, '2026-04-15'),
(3, 2, 'US', 7.50, '2026-04-20'),
(4, 3, 'UK', 99.00, '2026-04-22'),
(5, 3, 'UK', 12.00, '2026-04-25'),
(6, 4, 'IN', 5.00, '2026-04-28'),
(7, 5, 'UK', NULL, '2026-04-29');
“Total spend per country”:
SELECT country,
COUNT(*) AS n_orders,
SUM(total) AS revenue,
AVG(total) AS avg_order,
MIN(total) AS min_order,
MAX(total) AS max_order
FROM orders
GROUP BY country
ORDER BY revenue DESC NULLS LAST;
+---------+----------+---------+-----------+-----------+-----------+
| country | n_orders | revenue | avg_order | min_order | max_order |
+---------+----------+---------+-----------+-----------+-----------+
| UK | 3 | 111.00 | 55.50 | 12.00 | 99.00 |
| US | 3 | 69.49 | 23.16 | 7.50 | 42.00 |
| IN | 1 | 5.00 | 5.00 | 5.00 | 5.00 |
+---------+----------+---------+-----------+-----------+-----------+
UK has 3 orders summing to 111 — note: row 7 has total = NULL and it was
silently dropped from SUM and AVG. That’s a feature, not a bug, but
worth knowing (see below).
The aggregate functions worth memorizing
| Function | Returns | NULL handling |
|---|---|---|
COUNT(*) | Number of rows in the group, including all-NULL rows. | Counts NULL rows. |
COUNT(col) | Number of non-NULL values of col. | Skips NULL. |
COUNT(DISTINCT col) | Number of distinct non-NULL values. | Skips NULL. |
SUM(col) | Sum of non-NULL values. NULL if all values are NULL. | Skips NULL. |
AVG(col) | Average of non-NULL values. | Skips NULL. |
MIN(col), MAX(col) | Smallest / largest non-NULL value. | Skips NULL. |
STRING_AGG(col, sep) | Concatenate values with a separator (Postgres). GROUP_CONCAT in MySQL, LISTAGG in Snowflake. | Skips NULL. |
ARRAY_AGG(col) | Collect values into an array. | Includes NULL. |
BOOL_AND(col), BOOL_OR(col) | ”All true” / “any true” over a boolean. | Skips NULL. |
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY col) | Median (and other percentiles). Slow. | Skips NULL. |
COUNT(*) vs COUNT(col) is the most common subtle bug. If you want to
count rows, use *. If you want to count “rows where this column has a
value”, use COUNT(col).
The GROUP BY rule, in one sentence
Every non-aggregated column in
SELECTmust appear inGROUP BY.
Why: the engine produces one row per group. If you ask for country and
SUM(total) per country, that’s well-defined — one country, one sum. If
you also ask for placed without grouping by it, which date does it pick?
There’s no good answer, so SQL says “no”.
-- ERROR: column "placed" must appear in the GROUP BY clause
SELECT country, placed, SUM(total)
FROM orders
GROUP BY country;
Fix: either group by placed too (gives one row per country-date), or
aggregate it (MAX(placed), MIN(placed)):
SELECT country, MAX(placed) AS last_order, SUM(total) AS revenue
FROM orders
GROUP BY country;
MySQL note. Old MySQL versions allowed bare columns in
SELECTwithout grouping; it returned an arbitrary value from the group. This caused a generation of subtle bugs. Modern MySQL withONLY_FULL_GROUP_BYbehaves like Postgres. Don’t rely on the old behavior.
Grouping by multiple columns
SELECT country, EXTRACT(WEEK FROM placed) AS wk, SUM(total)
FROM orders
GROUP BY country, EXTRACT(WEEK FROM placed)
ORDER BY country, wk;
One row per (country, week) pair. Use this for cohort tables, daily/weekly
breakdowns, segment × period analysis.
You can also group by position (GROUP BY 1, 2) or by SELECT alias in
some dialects (Postgres allows it; standard SQL does not). Position is fine
for ad-hoc queries; brittle in production.
Filtering inside aggregates: FILTER and CASE
A pattern that quietly replaces 80% of subqueries: aggregate only certain rows.
-- Postgres / Snowflake / DuckDB: FILTER clause
SELECT country,
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE total > 50) AS big_orders,
SUM(total) FILTER (WHERE placed >= '2026-04-20') AS late_revenue
FROM orders
GROUP BY country;
+---------+--------------+------------+--------------+
| country | total_orders | big_orders | late_revenue |
+---------+--------------+------------+--------------+
| US | 3 | 0 | 7.50 |
| UK | 3 | 1 | 111.00 |
| IN | 1 | 0 | 5.00 |
+---------+--------------+------------+--------------+
FILTER (WHERE ...) is cleaner than the older SUM(CASE WHEN cond THEN x ELSE 0 END) idiom, but the CASE form works in every dialect:
-- Portable equivalent
SELECT country,
SUM(CASE WHEN total > 50 THEN 1 ELSE 0 END) AS big_orders
FROM orders
GROUP BY country;
This is the fundamental conditional-aggregation trick. Use it for ratios, funnel conversion rates, and “X out of Y” features.
ROLLUP, CUBE, GROUPING SETS
When you want subtotals at multiple levels of the hierarchy.
SELECT country,
EXTRACT(MONTH FROM placed) AS mo,
SUM(total) AS revenue
FROM orders
GROUP BY ROLLUP (country, mo);
ROLLUP(a, b) produces groups for (a, b), (a), and () (the grand
total). CUBE(a, b) adds (b) too — every subset. GROUPING SETS lets
you list arbitrary combinations.
These exist in Postgres, Snowflake, BigQuery, and most modern DBs. They replace the old “UNION ALL of subtotals” pattern. Use sparingly — they’re a hint that what you actually want is a BI tool.
Common pitfalls
COUNT(NULL)is 0, not NULL.COUNT(col)over an all-NULL group returns 0;SUM(col)over an all-NULL group returns NULL. Easy to get wrong when filling missing data.AVGover a column with NULLs is NOT total/row-count. It’sSUM(non-null) / COUNT(non-null). If you want NULLs treated as zero, useAVG(COALESCE(col, 0))orSUM(col) / COUNT(*).- Joining before grouping inflates aggregates. Joining
orderstoorder_itemsand thenSUM(orders.total)over-counts because each order’s total appears once per item. Pre-aggregate one side. GROUP BYon a computed expression you forgot to also put inSELECT. Confusing but legal — common with date truncation:GROUP BY DATE_TRUNC('day', placed)without selecting the truncated date back out.- Forgetting that
HAVINGexists. Filtering aggregated results withWHEREerrors out (WHEREruns before grouping). UseHAVING. See SQL 104.
Production patterns for ML
1. The “feature aggregations” template. Almost every per-user feature is an aggregation over a window:
SELECT user_id,
COUNT(*) AS orders_30d,
SUM(total) AS spend_30d,
AVG(total) AS avg_basket_30d,
SUM(total) FILTER (WHERE category = 'electronics') AS electronics_30d,
MAX(placed) AS last_order_30d
FROM orders
WHERE placed >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY user_id;
Run the same template with 7-day, 30-day, 90-day windows to get a multi-horizon feature set. This pattern alone produces hundreds of features with almost no code.
2. The “funnel” template. Conditional aggregation over event types:
SELECT user_id,
COUNT(*) FILTER (WHERE event = 'view') AS views,
COUNT(*) FILTER (WHERE event = 'add_cart') AS adds,
COUNT(*) FILTER (WHERE event = 'purchase') AS purchases,
1.0 * COUNT(*) FILTER (WHERE event = 'purchase')
/ NULLIF(COUNT(*) FILTER (WHERE event = 'view'), 0) AS conv_rate
FROM events
WHERE ts >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY user_id;
NULLIF(x, 0) avoids a divide-by-zero error and produces NULL instead —
which AVG and downstream nicely handle.
Practice problems
| # | Question |
|---|---|
| 1 | Top 10 customers by total payment, with their payment count and average payment. |
| 2 | For each film category, how many films, average length, and total rental count? |
| 3 | Per-month revenue (DATE_TRUNC('month', payment_date)) for the last 12 months. |
| 4 | For each store, count of customers, count of inactive customers (active = 0), and the percentage active. |
| 5 | Films rented at least 30 times and with average rental duration > 5 days. |
Resources
- PostgreSQL — aggregate functions — postgresql.org/docs
- Mode Analytics — GROUP BY — mode.com/sql-tutorial
- Use The Index, Luke — use-the-index-luke.com — when grouping is slow, indexing is usually why.
- PostgreSQL documentation — postgresql.org/docs