Skip to content
OrionStats
Module 2 · Aggregation and Logic · Aggregating data

Aggregations, GROUP BY and HAVING

Summarise thousands of rows into business metrics, break them down by dimension, and filter groups.

Beginner50 min
Recommended first

This lesson builds on: NULL and CASE WHEN — missing values and conditional logic.

Learning objectives
  • 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;
Result · 1 row
order_linesordersunitsavg_unit_pricecheapest_line_pricemost_expensive_line_price
756003765110629432.982.55508.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;
Result · 3 rows
statusordersavg_discount_pct
completed342583.1
returned22653
cancelled11283

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;
Result · 6 rows
yearchannelorders
2023online2532
2023store7736
2024online2476
2024store8801
2025online2851
2025store9862

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;
Result · first 10 rows
customer_idcompleted_orders
368086
246683
279377
52574
92974
21665
290964
385362
394662
167461

Read it as: among completed orders, count orders per customer, keep customers with at least 40, sort.

WHERE when you can, HAVING when you must

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;
Result · 6 rows
categorynet_revenueunits
Electronics170184611023
Home & Kitchen4356476428
Beauty41545223123
Grocery40814656604
Apparel2697415291
Sports1541463825

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

Average of averages

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.

COUNT(*) after a join

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.

Log in to answer and get feedback

Return customer_id and the number of completed orders for customers with more than 50 completed orders.

Log in to answer and get feedback

How many distinct customers placed at least one order in 2024 (any status)? One number.

Log in to answer and get feedback

Store A: 10 orders averaging 200. Store B: 990 orders averaging 50. What is the average order value across both stores?

Log in to answer and get feedback

Summary

  • Aggregates collapse rows: COUNT, SUM, AVG, MIN, MAX, COUNT(DISTINCT …).
  • GROUP BY produces one row per group; every selected column is grouped or aggregated.
  • WHERE filters rows before grouping; HAVING filters groups after.
  • Beware averages of averages and counting rows after joins.