Beginner: asking questions · Aggregation: GROUP BY and HAVING · topic 20 of 62 · 40 min
Writing reports with aggregation
Explanation
From question to query
Business people do not ask for GROUP BY; they ask "How did we do each month?" Before typing, answer four small questions. Your query almost writes itself after that.
- What is one row of the answer? (one month, one category, one city)
- Which rows count? (not cancelled orders, only 2025): this becomes WHERE.
- What do we group by? (the month, the category): this becomes GROUP BY.
- What number do we want per group? (sum of amount, count of orders): this becomes the aggregate. Then: which groups to keep (HAVING), how to sort (ORDER BY), and how many to show (LIMIT).
The practice data: a sales view
Revenue questions need the order date and status from orders together with qty and price from order_items. Combining tables is a JOIN (module 5). For now, the tasks below give you a ready-made view called sales: a saved query that behaves like a table, with one row per order line and the columns order_id, order_date, status, city, product, category, qty, price and amount (= qty × price). Views are covered in module 10.
Monthly revenue
SELECT strftime('%Y-%m', order_date) AS month, SUM(amount) AS revenue
FROM sales
WHERE status <> 'Cancelled'
GROUP BY month
ORDER BY month;
-- 2025-01 | 3779
-- 2025-02 | 2248
-- 2025-03 | 7167
-- 2025-04 | 650The top category
SELECT category, SUM(amount) AS revenue
FROM sales
WHERE status <> 'Cancelled'
GROUP BY category
ORDER BY revenue DESC
LIMIT 1;
-- Fashion | 5196Shares and percentages
SELECT ROUND(100.0 * SUM(CASE WHEN status = 'Delivered' THEN 1 ELSE 0 END) / COUNT(*), 1)
AS delivered_pct
FROM orders;
-- 50 (4 of 8 orders)Say clearly what the percentage is "of". 50% of orders are delivered, but measured by value, delivered orders are 64.4% of everything ordered, because the delivered orders are larger. Both are correct answers to different questions.
Check that totals add up
A report that does not add up loses trust fast. After grouping, compare the sum of the groups with a total worked out separately. Here the four months add to 13844, and SELECT SUM(amount) FROM sales WHERE status <> 'Cancelled' also gives 13844. Category shares should add to about 100% (rounding can give 99.9 or 100.1). If they do not, a filter or a group is wrong.
| Check | How |
|---|---|
| Groups add up to the total | Run the same WHERE without GROUP BY and compare |
| Row counts make sense | COUNT(*) and COUNT(DISTINCT order_id): lines versus orders |
| No group missing | Look for a NULL group or a status you did not expect |
| Percentages near 100 in total | Add the shares; differences of 0.1 come from rounding |
Labels and rounding
Name every column for the reader (revenue, delivered_pct, avg_order), round money to 2 decimals and percentages to 1, and keep units clear: COMPANY salaries are monthly, so say "monthly" or multiply by 12 and say "annual".
Common mistakes
- Counting after a filter: if WHERE status = 'Delivered' runs first, then SUM(...)/COUNT(*) gives 100%, because only delivered rows are left. Count the part with CASE and the whole with COUNT(*) in the same query, without that WHERE.
- Averaging averages. The average of the three course averages is 72.22, but the average over all students with marks is 72. Groups of different sizes get equal weight when you average averages. Go back to the rows: SUM / COUNT over everything.
- Counting lines instead of orders. sales has 12 rows but only 8 orders; use COUNT(DISTINCT order_id) for orders.
- Forgetting to exclude cancelled orders from revenue, or excluding them from one number but not another in the same report.
- Sorting months as text like "Mar 2025": they sort alphabetically. Group and sort by 'YYYY-MM', and format names only for display.
Need help?
Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.
Try:
Watch the lesson
Writing reports with aggregation · 1/7
Reports start as business questions. Your job is to turn each question into one GROUP BY query.
Prefer a video by a teacher? Find free videos on “sql reporting queries monthly revenue group by percentage tutorial” on YouTube ↗
In real life: A monthly business review for an online store
At the start of each month, a small online store's owner looks at a one-page review: revenue per month, the best category, revenue per city, and the share of orders delivered. Each line is one GROUP BY query over the same sales data, run with the same WHERE (cancelled orders removed) so the numbers agree with each other, and a final check that the city totals add up to the monthly total.
SELECT city,
COUNT(DISTINCT order_id) AS orders,
SUM(amount) AS revenue,
ROUND(SUM(amount) * 1.0 / COUNT(DISTINCT order_id), 2) AS avg_order
FROM sales
WHERE status <> 'Cancelled'
GROUP BY city
ORDER BY revenue DESC;Practice (0/4 done)
Monthly revenue. From the sales view, leave out Cancelled orders and return month (order_date as 'YYYY-MM' text) and the sum of amount named revenue, one row per month, sorted by month from oldest to newest.
Which category earned the most? From the sales view, leaving out Cancelled orders, return category and the sum of amount named revenue for the single top category.
What share of all orders is delivered? From orders, return one column delivered_pct: the number of 'Delivered' orders × 100 divided by the number of all orders, rounded to 1 decimal place.
Now measure by value. From the sales view (all statuses), return one column delivered_value_pct: the sum of amount for 'Delivered' rows × 100 divided by the sum of all amounts, rounded to 1 decimal place.
Quick quiz
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- HAVING versus WHEREAggregation: GROUP BY and HAVING · same module
- Joining three or more tablesJOINs
- Counting and totals: aggregate functionsBackend Development · Databases and SQL basics
- GROUP BY and HAVINGBackend Development · SQL in depth
- Project: e-commerce analytics, and your next stepsInterview questions and practice projects
- Running totals, moving averages and framesWindow functions
Questions people ask about this
Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.
You write SELECT 100.0 * COUNT(*) / COUNT(*) FROM orders WHERE status = 'Delivered' to get the delivered share. What is wrong?
Answer: WHERE leaves only delivered rows, so the answer is always 100
Count the part with CASE and the whole with COUNT(*), without filtering the whole away.
Three courses have average marks 70.67, 73 and 73, with 3, 2 and 2 students with marks. Why is the average of these averages (72.22) not the overall average (72)?
Answer: Groups of different sizes get equal weight when you average averages
Go back to the rows: total marks / number of students with marks.
The sales view has one row per order line: 12 rows for 8 orders. How do you count orders?
Answer: COUNT(DISTINCT order_id)
Several lines share one order_id; DISTINCT counts each order once.
Which is a good check after building revenue per month?
Answer: The months add up to the total revenue with the same WHERE
Groups must add up to the total worked out separately.
Why group months by strftime('%Y-%m', order_date) rather than by month names like "Mar 2025"?
Answer: 'YYYY-MM' text sorts in time order; month names sort alphabetically
Format month names only for display, after sorting.
What is the PostgreSQL way to list names per group, separated by commas, in order?Related
Answer: STRING_AGG(name, ', ' ORDER BY name)
PostgreSQL uses STRING_AGG with ORDER BY inside the brackets.
Why use order_items.price rather than products.price for revenue?Related
Answer: order_items stores the price actually paid, which does not change when the product price changes later
History must keep the amount charged at the time.
What does SELECT city, SUM(amount) FROM orders GROUP BY city return?Related
Answer: One row per city with its total
GROUP BY city makes one group per city, and SUM runs on each group.
students has 8 rows in 5 different cities. How many rows does SELECT city, COUNT(*) FROM students GROUP BY city return?Related
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.
Next steps
- This is the last topic of “Aggregation: GROUP BY and HAVING”. The module exam comes next.
- Module 4 exam: Aggregation, GROUP BY and HAVING · 15 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, JOINs. 62 topics left in the course before the final exam.