Skip to content

Beginner: asking questions · Aggregation: GROUP BY and HAVING · topic 20 of 62 · 40 min

Writing reports with aggregation

Explanation

A report starts as a business question ("revenue per month", "which category sells most", "what share of orders is delivered"). Turn it into SQL by deciding what one result row means, which rows count, how to group and which aggregate to use. Then round and label the numbers, and check that the parts add up to the total before anyone relies on them.

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.

  1. What is one row of the answer? (one month, one category, one city)
  2. Which rows count? (not cancelled orders, only 2025): this becomes WHERE.
  3. What do we group by? (the month, the category): this becomes GROUP BY.
  4. 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

sql
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 | 650
You can GROUP BY an expression, and 'YYYY-MM' text sorts in time order, which is why ISO dates matter. SQLite, PostgreSQL and MySQL accept the alias month in GROUP BY; SQL Server does not, so there you repeat the expression.

The top category

sql
SELECT category, SUM(amount) AS revenue
FROM sales
WHERE status <> 'Cancelled'
GROUP BY category
ORDER BY revenue DESC
LIMIT 1;
-- Fashion | 5196
Group, sort the groups by the aggregate, keep the first. If two categories could tie, think about whether you want both (module 7 shows RANK).

Shares and percentages

sql
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.

CheckHow
Groups add up to the totalRun the same WHERE without GROUP BY and compare
Row counts make senseCOUNT(*) and COUNT(DISTINCT order_id): lines versus orders
No group missingLook for a NULL group or a status you did not expect
Percentages near 100 in totalAdd 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

Writing reports

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.

sql
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;
Delhi: 2 orders, 6497, average 3248.5. Pune: 4 orders, 6448, average 1612. Mumbai: 1 order, 899. Total 13844, the same as the monthly report.

Practice (0/4 done)

Task 1 · Code · SQL

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.

Task 2 · Code · SQL

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.

Task 3 · Code · SQL

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.

Task 4 · Code · SQL

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

Question 1

1.You write SELECT 100.0 * COUNT(*) / COUNT(*) FROM orders WHERE status = 'Delivered' to get the delivered share. What is wrong?

Question 2

2.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)?

Question 3

3.The sales view has one row per order line: 12 rows for 8 orders. How do you count orders?

Question 4

4.Which is a good check after building revenue per month?

Question 5

5.Why group months by strftime('%Y-%m', order_date) rather than by month names like "Mar 2025"?

Read more (free)

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.

From Conditional aggregation and string aggregation

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.

From Joining three or more tables

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.

From Counting and totals: aggregate functions

students has 8 rows in 5 different cities. How many rows does SELECT city, COUNT(*) FROM students GROUP BY city return?Related

Answer: 5

GROUP BY gives one row per group, and there are 5 cities.

From GROUP BY

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

  1. This is the last topic of “Aggregation: GROUP BY and HAVING”. The module exam comes next.
  2. Module 4 exam: Aggregation, GROUP BY and HAVING · 15 questions · 25 min, without help. 5 topics of this module still to finish first.
  3. Then the next module, JOINs. 62 topics left in the course before the final exam.

← Previous: Conditional aggregation and string aggregation