GROUP BY and HAVING¶
Aggregation is what turns raw transaction records into business metrics. GROUP BY collapses rows into groups and lets you compute totals, averages, and counts per group. HAVING filters those groups. Together, they answer questions like "which customers spent the most?" and "which products have low sell-through rates?"
Learning Objectives¶
- Group rows with
GROUP BYand compute aggregates - Use
COUNT,SUM,AVG,MIN,MAXcorrectly - Filter aggregated results with
HAVING - Understand the difference between
WHERE(pre-aggregation) andHAVING(post-aggregation) - Use
CASE WHENinside aggregates
Aggregate Functions¶
Before GROUP BY, you need to know the aggregate functions:
| Function | Returns | Example |
|---|---|---|
COUNT(*) |
Number of rows | COUNT(*) → total orders |
COUNT(column) |
Non-NULL values in column | COUNT(delivery_date) → delivered orders |
COUNT(DISTINCT col) |
Unique non-NULL values | COUNT(DISTINCT customer_id) → unique buyers |
SUM(column) |
Sum of values | SUM(order_total) → total revenue |
AVG(column) |
Average of values | AVG(unit_price) → mean product price |
MIN(column) |
Smallest value | MIN(order_date) → earliest order |
MAX(column) |
Largest value | MAX(order_total) → biggest single order |
GROUP BY Basics¶
GROUP BY splits rows into groups. The aggregate function then runs on each group separately.
-- How many orders per status?
SELECT
status,
COUNT(*) AS order_count
FROM orders
GROUP BY status
ORDER BY order_count DESC;
Sample output:
| status | order_count |
|---|---|
| completed | 42 |
| pending | 18 |
| cancelled | 9 |
| refunded | 3 |
-- Revenue per customer
SELECT
customer_id,
COUNT(*) AS total_orders,
SUM(order_total) AS total_revenue,
AVG(order_total) AS avg_order_value,
MAX(order_total) AS largest_order
FROM orders
WHERE status = 'completed'
GROUP BY customer_id
ORDER BY total_revenue DESC;
Sample output:
| customer_id | total_orders | total_revenue | avg_order_value | largest_order |
|---|---|---|---|---|
| C003 | 12 | 2195.50 | 182.96 | 389.00 |
| C001 | 9 | 1456.25 | 161.81 | 298.00 |
| C008 | 6 | 894.00 | 149.00 | 149.00 |
The GROUP BY Rule¶
Every column in SELECT must either be in GROUP BY, or be inside an aggregate function.
-- WRONG: customer_id is in GROUP BY, but first_name is not
SELECT customer_id, first_name, COUNT(*)
FROM orders
GROUP BY customer_id;
-- CORRECT option 1: add first_name to GROUP BY
GROUP BY customer_id, first_name;
-- CORRECT option 2: use a JOIN to get first_name after aggregation
SELECT o.customer_id, c.first_name, COUNT(*) AS order_count
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
GROUP BY o.customer_id, c.first_name;
MySQL's non-standard GROUP BY behaviour
MySQL in some modes allows SELECT columns that aren't in GROUP BY and aren't aggregated — it picks an arbitrary row's value. This looks like it works but produces random results. PostgreSQL (strict mode) and SQL Server reject this query entirely. Always follow the rule: GROUP BY or aggregate.
Grouping by Multiple Columns¶
-- Revenue by customer AND by month
SELECT
customer_id,
DATE_FORMAT(order_date, '%Y-%m') AS order_month, -- MySQL
-- TO_CHAR(order_date, 'YYYY-MM') AS order_month, -- PostgreSQL
COUNT(*) AS orders,
SUM(order_total) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY customer_id, DATE_FORMAT(order_date, '%Y-%m')
ORDER BY customer_id, order_month;
HAVING — Filtering Groups¶
WHERE filters rows before aggregation. HAVING filters groups after aggregation.
-- Customers with more than 5 completed orders
SELECT
customer_id,
COUNT(*) AS order_count
FROM orders
WHERE status = 'completed' -- filter rows first
GROUP BY customer_id
HAVING COUNT(*) > 5 -- then filter groups
ORDER BY order_count DESC;
Rule to remember
WHERE goes before GROUP BY. HAVING goes after GROUP BY. If the condition involves an aggregate function (COUNT, SUM, AVG), it must go in HAVING.
-- Products generating more than £500 in revenue
SELECT
product_id,
COUNT(*) AS times_ordered,
SUM(order_total) AS total_revenue
FROM orders
WHERE status = 'completed'
GROUP BY product_id
HAVING SUM(order_total) > 500
ORDER BY total_revenue DESC;
Wrong way:
COUNT(*) vs COUNT(column)¶
SELECT
COUNT(*) AS total_rows,
COUNT(delivery_date) AS delivered_orders, -- NULLs not counted
COUNT(DISTINCT customer_id) AS unique_customers
FROM orders;
Sample output:
| total_rows | delivered_orders | unique_customers |
|---|---|---|
| 72 | 49 | 28 |
COUNT(*) counts all rows including those with NULLs. COUNT(column) counts only non-NULL values in that column.
For mid-level analysts
Use COUNT(*) when you want total rows. Use COUNT(column) when you want to count non-NULL values (e.g., how many orders have been delivered). Use COUNT(DISTINCT column) when you want unique values.
CASE WHEN Inside Aggregates¶
CASE WHEN lets you write conditional logic. Inside an aggregate, it's a powerful pattern for pivoting data.
-- Count orders by status in a single row (pivot)
SELECT
COUNT(*) AS total_orders,
COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed,
COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending,
COUNT(CASE WHEN status = 'cancelled' THEN 1 END) AS cancelled,
ROUND(
COUNT(CASE WHEN status = 'completed' THEN 1 END) * 100.0 / COUNT(*), 1
) AS completion_rate_pct
FROM orders;
Sample output:
| total_orders | completed | pending | cancelled | completion_rate_pct |
|---|---|---|---|---|
| 72 | 42 | 18 | 9 | 58.3 |
-- Revenue split by customer segment
SELECT
c.segment,
COUNT(DISTINCT o.customer_id) AS customers,
SUM(o.order_total) AS revenue,
AVG(o.order_total) AS avg_order_value
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.status = 'completed'
GROUP BY c.segment
ORDER BY revenue DESC;
Real Business Example — Monthly Revenue Report¶
SELECT
DATE_FORMAT(order_date, '%Y-%m') AS month,
COUNT(*) AS orders,
COUNT(DISTINCT customer_id) AS unique_customers,
SUM(order_total) AS gross_revenue,
AVG(order_total) AS avg_order_value,
MIN(order_total) AS min_order,
MAX(order_total) AS max_order
FROM orders
WHERE status = 'completed'
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
ORDER BY month;
Sample output:
| month | orders | unique_customers | gross_revenue | avg_order_value | min_order | max_order |
|---|---|---|---|---|---|---|
| 2024-01 | 18 | 12 | 4829.50 | 268.31 | 35.99 | 389.00 |
| 2024-02 | 24 | 16 | 6145.00 | 255.96 | 29.99 | 478.00 |
Practice Exercises¶
Warm-up
1. Count the number of orders per status.
2. Calculate total revenue per product.
3. Find the average unit_price per product category.
Main 4. Find the top 5 customers by total revenue (completed orders only). Show customer ID, order count, and total revenue. 5. For each month in 2024, calculate total orders and total revenue. 6. Find product categories where the average order value is above £150. 7. Count how many orders each customer placed. Show only customers with MORE than 3 orders.
Stretch 8. Write a single query that calculates for each customer: total orders, completed orders, cancelled orders, and the cancellation rate as a percentage. Sort by cancellation rate descending. 9. Find months where revenue dropped compared to the previous month. (Hint: this needs a subquery or window function — preview 04-window-functions.)
Interview Questions¶
[Beginner] What is the difference between WHERE and HAVING?
Show answer
WHERE filters individual rows before grouping. HAVING filters groups after aggregation.
Use WHERE when the condition involves column values. Use HAVING when the condition involves an aggregate (COUNT, SUM, AVG, etc.).
[Beginner] What is the difference between COUNT(*) and COUNT(column_name)?
[Mid-level] A colleague writes SELECT customer_id, first_name, COUNT(*) FROM orders GROUP BY customer_id. In PostgreSQL this throws an error. Why? How do you fix it?
[Mid-level] Write a query that calculates the percentage of orders that are completed vs. total orders, without using a subquery.
[Senior] You have a sales table with 200 million rows. A GROUP BY region, month query takes 5 minutes. What are your options to speed it up?