Aggregations, GROUP BY and HAVING
Summarise thousands of rows into business metrics, break them down by dimension, and filter groups.
This lesson builds on: NULL and CASE WHEN — missing values and conditional logic.
- Use COUNT, SUM, AVG, MIN and MAX correctly, including COUNT(DISTINCT)
- Group results by one or more columns with GROUP BY
- Filter groups with HAVING and explain the difference from WHERE
- Avoid the "non-aggregated column" error and average-of-averages mistakes
Aggregate functions
An aggregate function turns many rows into one value:
| Function | Returns |
|---|---|
COUNT(*) |
number of rows |
COUNT(col) |
number of non-NULL values |
COUNT(DISTINCT col) |
number of different non-NULL values |
SUM(col) |
total |
AVG(col) |
arithmetic mean (of non-NULL values) |
MIN(col), MAX(col) |
smallest / largest |
Revenue at line level is quantity × unit_price × (1 − discount_pct). Total 2025 net revenue from completed orders requires the order status, which lives in orders — so this preview uses a filter on order_items only to show the functions:
SELECT COUNT(*) AS order_lines,
COUNT(DISTINCT order_id) AS orders,
SUM(quantity) AS units,
ROUND(AVG(unit_price), 2) AS avg_unit_price,
MIN(unit_price) AS cheapest_line_price,
MAX(unit_price) AS most_expensive_line_price
FROM order_items;| order_lines | orders | units | avg_unit_price | cheapest_line_price | most_expensive_line_price |
|---|---|---|---|---|---|
| 75600 | 37651 | 106294 | 32.98 | 2.55 | 508.84 |
GROUP BY: one result row per group
GROUP BY splits rows into groups that share the same values and computes aggregates per group:
SELECT status,
COUNT(*) AS orders,
ROUND(AVG(discount_pct) * 100, 1) AS avg_discount_pct
FROM orders
GROUP BY status
ORDER BY orders DESC;| status | orders | avg_discount_pct |
|---|---|---|
| completed | 34258 | 3.1 |
| returned | 2265 | 3 |
| cancelled | 1128 | 3 |
Group by several columns for a breakdown:
SELECT substr(order_date, 1, 4) AS year,
channel,
COUNT(*) AS orders
FROM orders
WHERE status = 'completed'
GROUP BY substr(order_date, 1, 4), channel
ORDER BY year, channel;| year | channel | orders |
|---|---|---|
| 2023 | online | 2532 |
| 2023 | store | 7736 |
| 2024 | online | 2476 |
| 2024 | store | 8801 |
| 2025 | online | 2851 |
| 2025 | store | 9862 |
substr(order_date, 1, 4) takes the first four characters of the ISO date — the year. (The date-functions lesson shows the proper date functions in each dialect.)
The golden rule
Every column in SELECT must either be in the GROUP BY or inside an aggregate. SELECT channel, store_id, COUNT(*) … GROUP BY channel is an error in PostgreSQL and SQL Server, because there are many store_ids per channel and the database cannot know which one to show. (SQLite and MySQL in some modes silently pick an arbitrary value — a dangerous source of wrong reports.)
HAVING: filter groups
WHERE filters rows before grouping; HAVING filters groups after aggregation:
SELECT customer_id,
COUNT(*) AS completed_orders
FROM orders
WHERE status = 'completed' -- row filter
GROUP BY customer_id
HAVING COUNT(*) >= 40 -- group filter
ORDER BY completed_orders DESC;| customer_id | completed_orders |
|---|---|
| 3680 | 86 |
| 2466 | 83 |
| 2793 | 77 |
| 525 | 74 |
| 929 | 74 |
| 216 | 65 |
| 2909 | 64 |
| 3853 | 62 |
| 3946 | 62 |
| 1674 | 61 |
Read it as: among completed orders, count orders per customer, keep customers with at least 40, sort.
If a condition does not involve an aggregate, put it in WHERE. It reduces the rows grouped (faster) and makes intent clear.
Worked example: revenue by category
Revenue needs columns from order_items (quantity, price, discount) and products (category) — that requires a join, the topic of the next chapter. As a preview of how aggregation and joins combine:
SELECT p.category,
ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct)), 0) AS net_revenue,
SUM(oi.quantity) AS units
FROM order_items AS oi
JOIN products AS p ON p.product_id = oi.product_id
GROUP BY p.category
ORDER BY net_revenue DESC;| category | net_revenue | units |
|---|---|---|
| Electronics | 1701846 | 11023 |
| Home & Kitchen | 435647 | 6428 |
| Beauty | 415452 | 23123 |
| Grocery | 408146 | 56604 |
| Apparel | 269741 | 5291 |
| Sports | 154146 | 3825 |
Interpretation: Electronics and Home & Kitchen generate most revenue even though Grocery sells many units at low prices — volume and value tell different stories. (This query still includes cancelled and returned orders; the joins lesson fixes that.)
Common mistakes
The average order value across stores is not the average of each store's average — stores with few orders would count as much as big stores. Compute SUM(revenue) / COUNT(orders) over all rows instead.
After joining orders to order lines, COUNT(*) counts lines, not orders. Use COUNT(DISTINCT o.order_id).
Practice
Return each product category and its number of products. Two columns.
Return customer_id and the number of completed orders for customers with more than 50 completed orders.
How many distinct customers placed at least one order in 2024 (any status)? One number.
Store A: 10 orders averaging 200. Store B: 990 orders averaging 50. What is the average order value across both stores?
Summary
- Aggregates collapse rows:
COUNT,SUM,AVG,MIN,MAX,COUNT(DISTINCT …). GROUP BYproduces one row per group; every selected column is grouped or aggregated.WHEREfilters rows before grouping;HAVINGfilters groups after.- Beware averages of averages and counting rows after joins.