In the previous lesson you got a single figure for the whole company: €727.95 of revenue. It's a fact, but it isn't an analysis. What management really asks isn't "how much have we sold?" but "how much have we sold of each thing?": by category, by customer, by country, by month, by sales rep. That word —by— is the unmistakable sign that it's time for GROUP BY.

GROUP BY splits the set of rows into groups and applies the aggregate to each group separately, returning one row per group instead of one for everything. It is, without exaggeration, the clause that turns SQL into an analysis tool. In this lesson you'll learn to group by one column, by several and by an expression; you'll finally extend the logical execution order diagram with GROUP BY and HAVING —we promised it in 02-01—; you'll see why NULLs form a group of their own; you'll master the central pattern of analysis, which is GROUP BY combined with JOIN; and you'll discover why GreenStore's category 6 disappears from your reports and what to do to make it appear with an honest 0.

Contents

  1. GROUP BY: splitting into groups and aggregating each one
  2. The logical execution order, extended
  3. The golden rule: grouped or aggregated
  4. Grouping by one column
  5. Grouping by several columns
  6. Grouping by an expression
  7. GROUP BY and the SELECT's alias
  8. Groups and NULL
  9. GROUP BY with JOIN: the central pattern of analysis
  10. Empty groups: why category 6 doesn't show up
  11. Sorting by the aggregate and keeping the top N
  12. ROLLUP, GROUPING SETS and CUBE
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. GROUP BY: splitting into groups and aggregating each one

The idea is visual. With no GROUP BY, every row goes into a single pile. With GROUP BY, they're dealt into piles according to a column's value, and the aggregate is computed within each pile:

flowchart LR
    subgraph A["20 orders"]
      direction TB
      A1["delivered ×14"]
      A2["shipped ×2"]
      A3["paid ×2"]
      A4["cancelled ×1"]
      A5["pending ×1"]
    end
    A --> B["GROUP BY status"]
    B --> C["5 groups"]
    C --> D["COUNT(*) in each one<br/>→ 5 result rows"]

And the query:

SELECT status,
       COUNT(*) AS orders
FROM orders
GROUP BY status
ORDER BY orders DESC, status;
status orders
delivered 14
paid 2
shipped 2
cancelled 1
pending 1

5 rows, one per status. Compare that with what you'd have had to do without GROUP BY: five queries with WHERE status = '…', or one with five COUNT(*) FILTER (...). And if a sixth status appeared tomorrow, GROUP BY would show it on its own, whereas the other two versions would have to be rewritten.

The general principle: GROUP BY produces one row per distinct combination of values of the grouped columns. The result's row count is exactly the number of distinct values, not one more and not one less.

  1. The logical execution order, extended

Since 02-01 you've been building a diagram of the order in which SQL understands a query. Module 2 left it at five steps and module 3 showed that JOINs happen inside the FROM. Now we insert the two missing pieces, between WHERE and SELECT:

flowchart LR
    A["1 · FROM / JOIN<br/>where the rows come from"] --> B["2 · WHERE<br/>filters ROWS"]
    B --> C["3 · GROUP BY<br/>forms the GROUPS"]
    C --> D["4 · HAVING<br/>filters GROUPS"]
    D --> E["5 · SELECT<br/>projects and computes<br/>aliases are born here"]
    E --> F["5b · DISTINCT<br/>removes duplicates"]
    F --> G["6 · ORDER BY<br/>sorts the result"]
    G --> H["7 · LIMIT / OFFSET<br/>trims"]
Step Clause What it does Works over Lesson
1 FROM / JOIN Determines the starting set of rows Tables 02-01 / module 3
2 WHERE Discards rows Individual rows 02-03
3 GROUP BY Deals the surviving rows into groups Rows 04-05
4 HAVING Discards whole groups Groups 04-06
5 SELECT Computes and projects the columns. Aliases are born here Groups (or rows, if there's no GROUP BY) 02-01 / 02-02
5b DISTINCT Removes duplicate rows from the result Result 02-04
6 ORDER BY Sorts Result 02-05
7 LIMIT / OFFSET Trims Result 02-06

This diagram isn't decoration: on its own it explains nearly everything that comes next. Three immediate consequences:

Consequence Why
WHERE can't use aggregate functions It runs at step 2, before the groups exist. There's nothing to aggregate yet. Hence the error aggregate functions are not allowed in WHERE
HAVING can use them It runs at step 4, when the groups are already formed and their aggregates computed. That's the whole of lesson 04-06
Neither WHERE nor GROUP BY nor HAVING sees the SELECT's aliases The aliases are born at step 5. ORDER BY, which comes later, does see them (02-05)

That last one has an important nuance in PostgreSQL, and we devote the whole of section 7 to it.

The reasoning to keep in mind always: once GROUP BY runs, individual rows stop existing as such. From step 3 onwards, the working set is no longer 47 order lines: it's 5 groups. Everything you write from there on has to make sense at group level.

  1. The golden rule: grouped or aggregated

It's a single sentence, and everything else follows from it:

Every expression in the SELECT must be (a) in the GROUP BY, or (b) inside an aggregate function. No exceptions.

Watching it fail is the best way to understand it:

-- ⚠️ INCORRECT
SELECT cat.name AS category,
       p.name   AS product,
       COUNT(*) AS products
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
GROUP BY cat.name;
ERROR:  column "p.name" must appear in the GROUP BY clause or be used in an aggregate function
LINE 3:        p.name   AS product,
               ^

And the engine is right. The "Food" group's row represents five products: the oil, the rice, the honey, the pasta and the tomato. Which of the five names should appear in that cell? There's no possible answer, so PostgreSQL refuses to invent one.

The three valid ways out:

-- ✅ a) Group by the product name too (but then there are no groups: each one is unique)
GROUP BY cat.name, p.name

-- ✅ b) Aggregate the product name
STRING_AGG(p.name, ', ' ORDER BY p.id) AS products

-- ✅ c) Take the column out of the SELECT
SELECT cat.name, COUNT(*) ...

Option b is especially useful, and it shows the rule isn't a whim:

SELECT cat.name AS category,
       COUNT(*) AS products,
       STRING_AGG(p.name, ' · ' ORDER BY p.id) AS list_
FROM products AS p
JOIN categories AS cat ON p.category_id = cat.id
GROUP BY cat.name
ORDER BY products DESC, category;
category products list_
Food 5 Extra virgin olive oil 500 ml · Organic brown rice 1 kg · Raw orange blossom honey 500 g · Spelt pasta 500 g · Organic crushed tomato 400 g
Drinks 4 Organic chamomile tea 20 bags · Ceremonial matcha green tea 30 g · Ginger kombucha 750 ml · Cold-pressed orange juice 1 L
Natural cosmetics 4 Aloe vera face cream 50 ml · Rosemary solid shampoo 80 g · Almond body oil 200 ml · Calendula lip balm 15 ml
Sustainable home 4 Concentrated eco laundry detergent 1 L · Loofah scrubber (pack of 3) · Reusable cotton bags (pack of 5) · Soy wax candles (pack of 2)
Personal hygiene 2 Bamboo toothbrush · Natural stick deodorant 50 g
Supplements 1 Spirulina capsules 120 units

6 rows, one per category, with the 20 products spread out: 5 + 4 + 4 + 4 + 2 + 1 = 20. STRING_AGG has answered "which ones?" without breaking the rule, because it's an aggregate.

A qualification to the rule

PostgreSQL is a bit cleverer than the statement suggests: if you group by a table's primary key, it lets you select any other column from that same table, because the PK functionally determines the rest of the values.

-- ✅ CORRECT: cat.id is the PK, so cat.name is determined
SELECT cat.id,
       cat.name AS category,
       COUNT(p.id) AS products
FROM categories AS cat
LEFT JOIN products AS p ON p.category_id = cat.id
GROUP BY cat.id
ORDER BY cat.id;
id category products
1 Food 5
2 Natural cosmetics 4
3 Sustainable home 4
4 Drinks 4
5 Personal hygiene 2
6 Supplements 1

It works because cat.id is a PRIMARY KEY: within a group with the same id there can only be one name, so there's no ambiguity. It's a very practical convenience —it saves you having to repeat five columns in the GROUP BY— and it's been part of the SQL standard since 1999.

Dialect note — and why MySQL's permissiveness is a trap.

Engine Does it allow a bare column with an aggregate? What it returns
PostgreSQL No (except for functional dependency on the PK) An explicit error
MySQL with ONLY_FULL_GROUP_BY (default since 5.7.5) No An explicit error
MySQL with ONLY_FULL_GROUP_BY off Yes An arbitrary value from any row in the group
SQLite Yes, always An arbitrary value (with the documented exception of MIN/MAX, where it returns that row's value)
SQL Server No An explicit error
Oracle No An explicit error

Permissive MySQL is the trap: the query doesn't fail, and in tests with little data it even seems to return "the first one", which is usually the one you expected. In production, with a different execution plan, it returns another. A report that said "Food — Extra virgin olive oil — 5 products" starts saying "Food — Organic crushed tomato — 5 products" without anybody having touched anything. If you work with MySQL, check that ONLY_FULL_GROUP_BY is on and don't turn it off.

  1. Grouping by one column

4.1. Orders by payment method

SELECT payment_method,
       COUNT(*)                     AS orders,
       SUM(shipping_cost)           AS total_shipping,
       ROUND(AVG(shipping_cost), 2) AS avg_shipping
FROM orders
GROUP BY payment_method
ORDER BY orders DESC, payment_method;
payment_method orders total_shipping avg_shipping
card 11 52.10 4.74
paypal 4 29.70 7.43
transfer 3 17.45 5.82
cash_on_delivery 2 19.00 9.50

4 rows adding up to 20 orders and €118.25 of shipping: the same master figures from 04-04, now broken down. And a story is already readable: card dominates (11 out of 20) and cash on delivery is the method with the most expensive shipping (€9.50 on average), which makes sense because those are the most distant deliveries.

4.2. Customers by country

SELECT country,
       COUNT(*)             AS customers,
       COUNT(DISTINCT city) AS cities,
       MIN(signup_date)     AS first_signup,
       MAX(signup_date)     AS last_signup
FROM customers
GROUP BY country
ORDER BY customers DESC, country;
country customers cities first_signup last_signup
Spain 11 7 2025-01-10 2026-01-08
France 2 2 2025-04-18 2025-05-02
Portugal 2 2 2025-03-21 2025-04-04

3 rows. Notice COUNT(DISTINCT city): 11 Spanish customers spread across only 7 cities, because Valencia holds four and Barcelona two.

4.3. Products by category, with price statistics

SELECT category_id,
       COUNT(*)             AS products,
       ROUND(AVG(price), 2) AS avg_price,
       MIN(price)           AS cheapest,
       MAX(price)           AS priciest,
       SUM(stock)           AS total_stock
FROM products
GROUP BY category_id
ORDER BY category_id;
category_id products avg_price cheapest priciest total_stock
1 5 6.18 1.95 12.50 850
2 4 11.54 4.60 18.90 330
3 4 10.09 5.50 13.75 265
4 4 8.90 3.25 22.00 370
5 2 5.65 3.50 7.80 315
6 1 16.40 16.40 16.40 55

6 rows, the 6 categories, because we're grouping the products table and every product has a category. Hold on to that detail: in section 10 you'll see that grouping the sales by category gives only 5 rows, and understanding why is the difference between an honest report and an incomplete one.

Note what happens with category 6: a single product, so AVG, MIN and MAX coincide. Aggregates over a one-row group return that same value.

  1. Grouping by several columns

When you list several columns in the GROUP BY, the engine forms one group per distinct combination of values.

SELECT c.country,
       o.status,
       COUNT(*)             AS orders,
       SUM(o.shipping_cost) AS shipping
FROM orders    AS o
JOIN customers AS c ON o.customer_id = c.id
GROUP BY c.country, o.status
ORDER BY c.country, o.status;
country status orders shipping
France delivered 2 25.00
France pending 1 12.50
Portugal delivered 2 19.80
Portugal shipped 1 9.90
Spain cancelled 1 4.95
Spain delivered 10 31.25
Spain paid 2 9.90
Spain shipped 1 4.95

8 rows. Watch out for an important point: it is not 3 countries × 5 statuses = 15 rows. GROUP BY produces one row per combination that exists in the data, not per possible combination. There's no cancelled French order, so that row doesn't appear — it doesn't appear with a zero, it simply doesn't exist. (If you needed the complete grid, empty combinations included, the route would be 03-06's CROSS JOIN with a LEFT JOIN on top.)

The order of the columns in the GROUP BY doesn't change the result, only the mental reading. GROUP BY c.country, o.status and GROUP BY o.status, c.country return the same 8 groups. What does change the report's look is the ORDER BY.

And the classic analysis case: sales by category and year.

SELECT cat.name AS category,
       EXTRACT(YEAR FROM o.order_date) AS year_,
       COUNT(*) AS lines_,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN orders     AS o   ON ol.order_id   = o.id
JOIN products   AS p   ON ol.product_id = p.id
JOIN categories AS cat ON p.category_id = cat.id
GROUP BY cat.name, EXTRACT(YEAR FROM o.order_date)
ORDER BY category, year_;
category year_ lines_ revenue
Drinks 2025 9 146.55
Drinks 2026 2 48.73
Food 2025 14 215.67
Food 2026 2 40.60
Natural cosmetics 2025 8 128.22
Natural cosmetics 2026 2 28.10
Personal hygiene 2025 2 24.50
Personal hygiene 2026 1 7.00
Sustainable home 2025 7 88.58

9 rows, not 10: Sustainable home sold nothing in 2026, so that combination doesn't exist. It's the same phenomenon as before, and in a trend report it's exactly the kind of gap you have to know how to read.

(EXTRACT(YEAR FROM …) pulls the year out of a date. Date functions are studied in depth in 06-03; here it's just an instrument.)

  1. Grouping by an expression

You aren't limited to columns: you can group by any expression computed from them. It's what you've just done with EXTRACT, and it's what lets you build bands, cohorts and segmentations.

Let's group the catalogue by price band:

SELECT CASE
         WHEN price <  5  THEN '1 · under €5'
         WHEN price < 10  THEN '2 · €5 to €10'
         WHEN price < 15  THEN '3 · €10 to €15'
         ELSE                  '4 · €15 or more'
       END AS price_range,
       COUNT(*)             AS products,
       ROUND(AVG(price), 2) AS avg_price,
       MIN(price)           AS min_,
       MAX(price)           AS max_
FROM products
GROUP BY CASE
           WHEN price <  5  THEN '1 · under €5'
           WHEN price < 10  THEN '2 · €5 to €10'
           WHEN price < 15  THEN '3 · €10 to €15'
           ELSE                  '4 · €15 or more'
         END
ORDER BY price_range;
price_range products avg_price min_ max_
1 · under €5 7 3.56 1.95 4.95
2 · €5 to €10 6 7.79 5.40 9.90
3 · €10 to €15 4 12.93 11.20 14.25
4 · €15 or more 3 19.10 16.40 22.00

4 bands adding up to the 20 products. GreenStore's catalogue is clearly skewed towards cheap products: 13 of the 20 references cost less than €10.

Two things about this query deserve comment:

  1. The expression appears twice, identically, in the SELECT and in the GROUP BY. It's ugly and it's necessary, for the usual reason: the alias price_range doesn't exist yet when the GROUP BY runs. (Except in PostgreSQL, which makes a concession — section 7.) The ways of avoiding the repetition are module 7's subqueries and module 10's CTEs.
  2. The numeric prefix on the labels (1 · , 2 · …) isn't decorative. ORDER BY price_range sorts alphabetically, and without the prefix the order would be "€10 to €15", "€15 or more", "€5 to €10", "under €5": a mess. It's a common trick when building bands.

CASE is studied in depth in 06-05; here it's used as a classification tool.

  1. GROUP BY and the SELECT's alias

This point is explained badly in a lot of places, so let's be precise.

By the logical order, the GROUP BY (step 3) runs before the SELECT (step 5), where aliases are born. Therefore the SQL standard doesn't allow using a SELECT alias in the GROUP BY.

In practice, PostgreSQL makes a concession: it accepts a GROUP BY element being the name of an output column (an alias) or its ordinal number. It's an engine extension, documented and very convenient:

-- ✅ Works in PostgreSQL: 'year_' is a SELECT alias
SELECT EXTRACT(YEAR FROM order_date) AS year_,
       COUNT(*)           AS orders,
       SUM(shipping_cost) AS shipping
FROM orders
GROUP BY year_
ORDER BY year_;
year_ orders shipping
2025 16 85.95
2026 4 32.30

It also works by ordinal number, GROUP BY 1, though that form is fragile: if somebody adds a column at the start of the SELECT, the 1 starts referring to something else.

Now the three limitations of that concession, which are what produce the confusing errors.

Limitation 1: only a bare alias, not an expression using it

-- ⚠️ INCORRECT
SELECT ROUND(price) AS whole_price, COUNT(*)
FROM products
GROUP BY whole_price + 0;
ERROR:  column "whole_price" does not exist
LINE 3: GROUP BY whole_price + 0;
                 ^

As soon as the alias goes into a larger expression, PostgreSQL stops resolving it as an output name and looks for it as a table column, where it doesn't exist.

Limitation 2: in case of ambiguity, the input column wins

This is the dangerous one. If a SELECT alias clashes with the name of a real column in the table, PostgreSQL uses the table's column, not your alias:

-- ⚠️ INCORRECT: 'category_id' is both an alias and a real column
SELECT supplier_id AS category_id,
       COUNT(*)    AS products
FROM products
GROUP BY category_id;
ERROR:  column "products.supplier_id" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT supplier_id AS category_id,
               ^

The message looks absurd —"but I wrote GROUP BY category_id, which is exactly the alias of supplier_id"— until you understand the rule: the GROUP BY resolved category_id as products.category_id, the real column. And then supplier_id is left bare in the SELECT, which is exactly what the error is reporting. An alias that shadows a column name is always a bad idea.

Limitation 3: HAVING does not accept aliases

-- ⚠️ INCORRECT
SELECT category_id, COUNT(*) AS products
FROM products
GROUP BY category_id
HAVING products > 3;
ERROR:  column "products" does not exist
LINE 4: HAVING products > 3;
               ^

PostgreSQL's concession covers GROUP BY and ORDER BY, but not HAVING. There you have to repeat the whole aggregate: HAVING COUNT(*) > 3. Lesson 04-06 develops it.

Summary by clause and by engine

Clause Does it see the SELECT's aliases?
WHERE No, on any engine
GROUP BY Yes in PostgreSQL, MySQL and SQLite (an extension). No in SQL Server or Oracle before 23ai
HAVING No in PostgreSQL, SQL Server or Oracle. Yes in MySQL and SQLite
ORDER BY Yes on all of them

The course's recommendation: even though PostgreSQL allows it, repeat the full expression in the GROUP BY. It's longer, yes, but it's portable, it doesn't depend on name-resolution rules and it works the same way in all four clauses. Save aliases for the ORDER BY, where they're standard and hold no surprises.

  1. Groups and NULL

In 04-03 you saw the table of where NULLs are considered equal to each other, and GROUP BY was on the list. Here it is in action, and it's one of the most useful things in the whole lesson:

SELECT employee_id,
       COUNT(*)           AS orders,
       SUM(shipping_cost) AS shipping
FROM orders
GROUP BY employee_id
ORDER BY employee_id NULLS LAST;
employee_id orders shipping
4 4 22.40
5 4 32.30
6 2 17.45
(null) 10 46.10

4 groups, and the fourth is the NULL one: 10 web-channel orders with €46.10 of shipping. GROUP BY has bundled the ten nulls into a single group, even though NULL = NULL is UNKNOWN.

And this is exactly what you want: the web channel is a real business category and it deserves its row. Compare it with what would have happened if the design had used a sentinel (employee_id = 0): the group would exist all the same, but it would also sneak into COUNT(DISTINCT employee_id) as if it were a genuine sales rep, giving 4 instead of 3.

For the report to read well, the null row needs a label. COALESCE (06-04) or CASE (06-05) solves it; for now, the ORDER BY's NULLS LAST (02-05) at least puts it where it belongs.

The same with referrals:

SELECT referred_by_id,
       COUNT(*) AS customers
FROM customers
GROUP BY referred_by_id
ORDER BY customers DESC, referred_by_id NULLS LAST;
referred_by_id customers
(null) 7
1 3
2 1
5 1
6 1
7 1
9 1

7 rows. The null group (7 customers who arrived on their own) is the largest, and Lucía Martínez Soler (id 1) is the best advocate with 3 referrals. That's an actionable fact no previous query in the course could give.

  1. GROUP BY with JOIN: the central pattern of analysis

Now we reach what actually gets done every day in any company. 03-02's canonical four-table query is still the basis; all that changes is that we now put a GROUP BY on top of it.

9.1. Sales by category

SELECT cat.id,
       cat.name AS category,
       COUNT(*)         AS lines_,
       SUM(ol.quantity) AS units,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN products   AS p   ON ol.product_id = p.id
JOIN categories AS cat ON p.category_id = cat.id
GROUP BY cat.id, cat.name
ORDER BY revenue DESC, cat.id;
id category lines_ units revenue
1 Food 16 49 256.27
4 Drinks 11 29 195.28
2 Natural cosmetics 10 16 156.32
3 Sustainable home 7 10 88.58
5 Personal hygiene 3 9 31.50

These are GreenStore's revenue figures by category. They add up to €727.95, the total from 04-04. And they already tell a commercial story: Food leads in revenue and in units (49 of 113), while Natural cosmetics bills €156.32 on only 16 units — its value per unit is almost five times higher.

Five rows, not six. Supplements is missing. We'll come back to that in section 10.

9.2. Sales by customer

SELECT c.id,
       c.name || ' ' || c.last_name AS customer,
       c.country,
       COUNT(DISTINCT o.id) AS orders,
       COUNT(*)             AS lines_,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS total_spent
FROM order_lines AS ol
JOIN orders    AS o ON ol.order_id   = o.id
JOIN customers AS c ON o.customer_id = c.id
JOIN products  AS p ON ol.product_id = p.id
GROUP BY c.id, c.name, c.last_name, c.country
ORDER BY total_spent DESC, c.id;
id customer country orders lines_ total_spent
7 Sofia Moreira Costa Portugal 2 5 111.88
1 Lucía Martínez Soler Spain 3 9 107.60
9 Camille Dubois France 2 4 70.87
10 Julien Moreau France 1 3 66.90
4 Javier Ortega Ruiz Spain 2 4 62.93
2 Carlos Ferrer Ibáñez Spain 2 4 59.46
6 Pau Llorens Vidal Spain 2 3 57.33
5 Ana Belmonte Roca Spain 2 4 54.85
8 Tiago Almeida Nunes Portugal 1 3 44.60
12 Diego Ramos Herrera Spain 1 3 31.70
11 Elena Navarro Puig Spain 1 2 30.30
3 Marta Sanchis Gil Spain 1 3 29.53

12 rows —the 12 customers who have bought— and GreenStore's customer ranking: Sofia Moreira Costa leads with €111.88, followed very closely by Lucía Martínez Soler with €107.60 spread across three orders.

Two technical details worth seeing:

  • COUNT(DISTINCT o.id) is essential. COUNT(*) counts lines, not orders: Lucía has 9 lines across 3 orders. It's 03-02's row multiplication, and DISTINCT is how you undo it when counting.
  • Every non-aggregated column is in the GROUP BY. Since c.id is customers' PK, PostgreSQL would let us write just GROUP BY c.id; they've all been listed so the query is portable.

And the warning, now resolved: if you added SUM(o.shipping_cost) to this query you'd get an inflated number, because each order appears as many times as it has lines. Lucía would pay her shipping nine times. It's exactly the problem from 04-04 section 11, and the solution is the same: aggregate the shipping in a separate query (or, from module 7 onwards, with a subquery that collapses the lines before joining).

9.3. Units by product

SELECT p.id,
       p.name AS product,
       SUM(ol.quantity) AS units,
       COUNT(*)         AS times_sold,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN products AS p ON ol.product_id = p.id
GROUP BY p.id, p.name
ORDER BY units DESC, p.id
LIMIT 8;
id product units times_sold revenue
2 Organic brown rice 1 kg 14 4 54.60
5 Organic crushed tomato 400 g 14 2 23.79
16 Ginger kombucha 750 ml 12 3 56.43
1 Extra virgin olive oil 500 ml 9 5 109.53
14 Organic chamomile tea 20 bags 9 3 29.25
18 Bamboo toothbrush 9 3 31.50

(First 6 of 17 rows.)

17 rows in total, not 20: products 13, 19 and 20 have never been sold and don't appear. And look at the contrast between the two figures: the rice and the tomato tie on units (14), but the rice bills more than double because it costs twice as much. The best-selling product by units and the highest-billing one are almost never the same, and that's why a sales report needs both metrics.

  1. Empty groups: why category 6 doesn't show up

Go back to section 9.1: five categories in the sales report, six in the catalogue. Supplements has vanished.

It isn't a GROUP BY fault. It's 03-02's INNER JOIN doing what it does: discarding what doesn't match. The only Supplements product (the spirulina) doesn't appear in order_lines, so no row of the FROM belongs to that category, and a group with no rows doesn't exist.

GROUP BY doesn't invent groups: it only deals out the rows it's given.

flowchart TD
    A["categories: 6 rows"] --> B["INNER JOIN with the sales"]
    B --> C["Supplements doesn't match<br/>❌ discarded in the FROM"]
    C --> D["GROUP BY only sees 5 categories<br/>→ 5 rows"]
    A --> E["LEFT JOIN from categories"]
    E --> F["Supplements is kept<br/>with the sales at NULL"]
    F --> G["GROUP BY sees 6 categories<br/>→ 6 rows"]

The solution: a LEFT JOIN from the table that has to come out whole

SELECT cat.id,
       cat.name     AS category,
       COUNT(ol.id) AS lines_,
       COALESCE(SUM(ol.quantity), 0) AS units,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM categories AS cat
LEFT JOIN products    AS p  ON p.category_id = cat.id
LEFT JOIN order_lines AS ol ON ol.product_id = p.id
GROUP BY cat.id, cat.name
ORDER BY cat.id;
id category lines_ units revenue
1 Food 16 49 256.27
2 Natural cosmetics 10 16 156.32
3 Sustainable home 7 10 88.58
4 Drinks 11 29 195.28
5 Personal hygiene 3 9 31.50
6 Supplements 0 0 (null)

6 rows. There's Supplements, with the truth: 0 lines, 0 units and a null revenue.

Three decisions in that query deserve explaining, and they're the most important part of the section:

1. COUNT(ol.id) and not COUNT(*). This is critical. The Supplements row exists in the LEFT JOIN's result —with all of order_lines' columns at NULL— so COUNT(*) would count it and return 1, not 0. COUNT(ol.id) counts only the non-null values and returns the honest 0.

-- The difference, over the same query
COUNT(*)     -- Supplements: 1  ❌ there is a row, but there's no sale
COUNT(ol.id) -- Supplements: 0  ✅

It's 04-04 section 3's rule applied to the case that matters most. In any GROUP BY over a LEFT JOIN, always count a column from the right-hand table, never *.

2. The revenue comes out as *(null)* and not 0.00. Because SUM of an empty set is NULL (04-04, section 7). To present it as 0.00 you'd need COALESCE(SUM(...), 0), which is what we've done with the units. COALESCE is 06-04; here it's been used once so you can see the contrast between the two columns.

3. Both JOINs are LEFT. Remember 03-03 section 7's rule: an INNER JOIN after a LEFT JOIN cancels its effect. If the second hop were JOIN order_lines, Supplements would disappear again.

The same pattern over products

SELECT p.id,
       p.name       AS product,
       p.stock,
       p.active,
       COUNT(ol.id) AS times_sold,
       COALESCE(SUM(ol.quantity), 0) AS units
FROM products AS p
LEFT JOIN order_lines AS ol ON ol.product_id = p.id
GROUP BY p.id, p.name, p.stock, p.active
HAVING COUNT(ol.id) = 0
ORDER BY p.id;
id product stock active times_sold units
13 Soy wax candles (pack of 2) 0 true 0 0
19 Natural stick deodorant 50 g 75 true 0 0
20 Spirulina capsules 120 units 55 false 0 0

3 rows, the same three never-sold products you found with 03-03's anti-join, now with the diagnosis alongside: no stock, a commercial problem, discontinued. (The HAVING is the next lesson; it appears here in passing because it's the natural way of filtering by a count.)

The rule to take away: an INNER JOIN answers "how much has each category that has sold something sold?"; a LEFT JOIN answers "how much has each category sold?". The second is almost always the business question, because a zero is information too: it tells management that Supplements isn't working. A report that hides the zeros hides precisely the problems.

  1. Sorting by the aggregate and keeping the top N

ORDER BY runs after the SELECT (step 6 of the diagram), so it can sort by an aggregate or by its alias without a problem:

SELECT p.id,
       p.name AS product,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN products AS p ON ol.product_id = p.id
GROUP BY p.id, p.name
ORDER BY revenue DESC, p.id
LIMIT 5;
id product revenue
1 Extra virgin olive oil 500 ml 109.53
15 Ceremonial matcha green tea 30 g 88.00
6 Aloe vera face cream 50 ml 70.42
16 Ginger kombucha 750 ml 56.43
2 Organic brown rice 1 kg 54.60

GreenStore's top 5 products by revenue. The olive oil is the star product with €109.53, 15 % of all revenue.

Notice two things:

  • The ORDER BY ends with p.id, a unique column, as the course's convention has demanded since 02-05. Without it, two products with the same revenue could come out in a different order on each run and pagination would be unstable.
  • You can also sort by an aggregate that isn't in the SELECT: ORDER BY SUM(ol.quantity) DESC is perfectly legal even if you don't display the units. It's legal and sometimes confusing, so use it carefully.

The combination GROUP BY + ORDER BY aggregate DESC + LIMIT n is the top N pattern, and it's probably the most requested analytical query there is: the 10 biggest-spending customers, the 5 slowest-moving products, the 3 sales reps with the most sales.

Note: if what you want is a top N within each group —"the 3 best-selling products of each category"— GROUP BY and LIMIT aren't enough: you need a window function (ROW_NUMBER() OVER (PARTITION BY ...)), and that's module 10.

  1. ROLLUP, GROUPING SETS and CUBE

A real report nearly always needs subtotals and a grand total alongside the detail. Writing that with a UNION ALL of several queries is tedious and slow, because it forces the table to be traversed several times. SQL offers three GROUP BY extensions to solve it in a single pass:

Construct What it adds
ROLLUP (a, b) The (a,b) groups, plus the subtotals by a, plus the grand total. Hierarchical
CUBE (a, b) Every combination: (a,b), (a), (b) and the grand total
GROUPING SETS ((a,b), (a), ()) Exactly the sets you enumerate. It's the general form; ROLLUP and CUBE are shortcuts

A minimal example with ROLLUP, over section 5's sales by category and year:

SELECT cat.name AS category,
       EXTRACT(YEAR FROM o.order_date) AS year_,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN orders     AS o   ON ol.order_id   = o.id
JOIN products   AS p   ON ol.product_id = p.id
JOIN categories AS cat ON p.category_id = cat.id
GROUP BY ROLLUP (cat.name, EXTRACT(YEAR FROM o.order_date))
ORDER BY category NULLS LAST, year_ NULLS LAST;
category year_ revenue
Drinks 2025 146.55
Drinks 2026 48.73
Drinks (null) 195.28
Food 2025 215.67
Food 2026 40.60
Food (null) 256.27
Natural cosmetics 2025 128.22
Natural cosmetics 2026 28.10
Natural cosmetics (null) 156.32
Personal hygiene 2025 24.50
Personal hygiene 2026 7.00
Personal hygiene (null) 31.50
Sustainable home 2025 88.58
Sustainable home (null) 88.58
(null) (null) 727.95

15 rows: the 9 real combinations, 5 subtotals by category and the grand total of €727.95 in the last one. The subtotal rows are recognisable because the lower-level grouped columns are NULL.

And there's ROLLUP's only complication: those subtotal NULLs are indistinguishable from a data NULL. The GROUPING(column) function exists to tell them apart (it returns 1 if the row is a subtotal for that column), and combined with CASE (06-05) it lets you label the rows as "Category total" or "GRAND TOTAL".

We won't go deeper: ROLLUP and company are used a lot in management reports and in business intelligence tools, but their natural place is after you've mastered CASE and subqueries.

Dialect note: ROLLUP, CUBE and GROUPING SETS exist in PostgreSQL 9.5+, SQL Server and Oracle. MySQL only has GROUP BY ... WITH ROLLUP (a different syntax and no CUBE or GROUPING SETS). SQLite has none of them: you have to emulate them with UNION ALL.

Common Mistakes and Tips

  • Putting a column in the SELECT that's neither grouped nor aggregated. column ... must appear in the GROUP BY clause. It's the most frequent error of the lesson, and in permissive MySQL or in SQLite it raises no error: it returns an arbitrary value.
  • Using COUNT(*) in a GROUP BY over a LEFT JOIN. It returns 1 where it should return 0. Count a column from the right-hand table: COUNT(ol.id).
  • Expecting empty groups to appear with an INNER JOIN. Category 6 doesn't come out. If the report has to list every category, start from categories with a LEFT JOIN.
  • Slipping an INNER JOIN in after the LEFT JOIN in the same chain. It cancels the LEFT and the empty groups disappear again (03-03).
  • Summing a header column after joining with the detail. It's still 04-04's mistake: Lucía's shipping would be counted nine times.
  • Counting orders with COUNT(*) in a query that starts from order_lines. It counts lines. Use COUNT(DISTINCT o.id).
  • Using a SELECT alias in the HAVING. column "..." does not exist in PostgreSQL. Repeat the aggregate.
  • Assuming GROUP BY generates every possible combination. It only generates the ones present in the data: 8 country × status rows, not 15.
  • Sorting bands alphabetically with no numeric prefix. "€10 to €15" comes before "€5 to €10". Number the labels.
  • Forgetting the unique column at the end of the ORDER BY. With ties, the order stops being deterministic.
  • Tip: write the query without aggregating first and look at the rows. If the detail isn't what you expect, the aggregate you put on top will be wrong even if it compiles.
  • Tip: check that the groups add up to the total. 256.27 + 195.28 + 156.32 + 88.58 + 31.50 = €727.95. If it doesn't reconcile with the overall figure, there are rows lost or duplicated.
  • Tip: count the groups you expect before running it. If you group by status there should be at most 5 rows (the CHECK's domain); if 6 come out, there's an unexpected value in the data.

Exercises

Exercise 1

Logistics wants an activity-by-sales-rep report. Write a query over orders and employees that returns, for all 8 employees (whether or not they appear in orders): their id, full name, job title, number of orders handled and the sum of the shipping costs of those orders.

Sort by number of orders descending. Then answer:

  1. How many rows does it return and why?
  2. What would have happened with an INNER JOIN?
  3. Why can't the web channel appear here?

Exercise 2

Marketing wants to segment the catalogue by supplier. Write a query that returns, for each supplier: their name, their country, whether they're active, how many products they supply, the average price of those products (two decimals) and the units sold of all of them.

The inactive supplier must appear too. Then answer: how many units has EcoNordic Supplies sold despite being inactive, and what does that tell you about the business?

Exercise 3

Management wants the sales-by-month report for the whole history, with these columns: month (in YYYY-MM format), number of distinct orders, number of lines, units and revenue.

  1. Write it. (Hint: you can group by the expression TO_CHAR(o.order_date, 'YYYY-MM'), a string function you'll see in depth in 06-03.)
  2. Identify the best month and the worst.
  3. Explain why the sum of the monthly revenues must give €727.95 and check it.

Solutions

Solution 1

SELECT e.id,
       e.name || ' ' || e.last_name AS employee,
       e.job_title,
       COUNT(o.id)                          AS orders,
       COALESCE(SUM(o.shipping_cost), 0.00) AS shipping
FROM employees AS e
LEFT JOIN orders AS o ON o.employee_id = e.id
GROUP BY e.id, e.name, e.last_name, e.job_title
ORDER BY orders DESC, e.id;
id employee job_title orders shipping
4 Óscar Peris Blasco Sales rep 4 22.40
5 Laia Puig Sanchis Sales rep 4 32.30
6 Marc Estévez Roig Customer support 2 17.45
1 Rosa Alcázar Vives General manager 0 0.00
2 Andrés Company Talens Sales manager 0 0.00
3 Beatriz Nadal Ripoll Logistics manager 0 0.00
7 Irene Salvador Mira Warehouse operator 0 0.00
8 Daniel Vercher Lluch Data analyst 0 0.00

1. 8 rows, the 8 employees. The LEFT JOIN from employees keeps the five who have never handled an order, and COUNT(o.id) gives them a correct 0 (with COUNT(*) they'd have come out with 1). COALESCE turns SUM's NULL into a presentable 0.00.

2. With an INNER JOIN there'd be 3 rows: only Óscar, Laia and Marc. Rosa, Andrés, Beatriz, Irene and Daniel would disappear — which is exactly what happened in 03-04, where you met them as "the employees who have never handled an order". An HR report with 3 people out of 8 isn't a report.

3. The web channel can't appear because those 10 orders have employee_id at NULL and match no row of employees. Starting from employees with a LEFT JOIN, those rows sit on the right-hand side with no partner and are discarded: the sum of the orders column gives 10, not 20. To see them you'd have to start from orders (GROUP BY employee_id, section 8) or use a FULL OUTER JOIN (03-05). It's the LEFT JOIN's asymmetry in its purest form: it decides which side comes out whole, and the other loses its orphans.

Solution 2

SELECT s.id,
       s.name AS supplier,
       s.country,
       s.active,
       COUNT(DISTINCT p.id)          AS products,
       ROUND(AVG(p.price), 2)        AS avg_price,
       COALESCE(SUM(ol.quantity), 0) AS units_sold
FROM suppliers AS s
LEFT JOIN products    AS p  ON p.supplier_id = s.id
LEFT JOIN order_lines AS ol ON ol.product_id = p.id
GROUP BY s.id, s.name, s.country, s.active
ORDER BY units_sold DESC, s.id;
id supplier country active products avg_price units_sold
1 Huerta del Turia Spain true 5 6.73 53
2 BioSierra Ibérica Spain true 3 5.58 21
4 Maison Nature France true 4 10.57 14
3 Verde Atlántico Portugal true 4 13.52 13
5 EcoNordic Supplies Germany false 4 9.01 12

5 rows, the five suppliers, with the 20 products spread out: 5 + 3 + 4 + 4 + 4 = 20. Two important details of this query:

  • COUNT(DISTINCT p.id) and not COUNT(p.id). After the second LEFT JOIN, each product appears as many times as it has been sold: the oil would be there five times. COUNT(p.id) would give 16 for Huerta del Turia instead of 5. It's 03-02's row multiplication, and DISTINCT is the correction.
  • AVG(p.price) is affected too, and that one can't be fixed with DISTINCT. Huerta del Turia's average price comes out as €6.73, not the simple mean of its five products (which is €5.74), because the mean is weighted by the number of times each reference has been sold: the €12.50 oil goes in five times and the €1.95 tomato only twice. It's a legitimate average —"average price of what gets billed"— but it isn't the one the exercise asked for. For the catalogue's average price you have to compute it in a separate query over products, or with a subquery (module 7). It's exactly 04-04's granularity trap, now over an average instead of a sum, and it's more insidious because the resulting number looks plausible.

EcoNordic Supplies has sold 12 units despite being inactive, because suppliers.active = false means "we don't buy from them any more", not "their products disappear from the catalogue". Its four references are the detergent (10), the candles (13), the bamboo toothbrush (18) and the spirulina (20); two of them have never been sold, but the bamboo toothbrush has shifted 9 units and the detergent 3. The actionable fact is clear: there are two products selling normally whose supplier is no longer operational. That's a supply problem just around the corner, and this query is exactly the one that detects it.

Solution 3

SELECT TO_CHAR(o.order_date, 'YYYY-MM') AS month,
       COUNT(DISTINCT o.id)             AS orders,
       COUNT(*)                         AS lines_,
       SUM(ol.quantity)                 AS units,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
GROUP BY TO_CHAR(o.order_date, 'YYYY-MM')
ORDER BY month;
month orders lines_ units revenue
2025-03 2 5 10 68.80
2025-04 2 5 14 61.28
2025-05 2 5 6 58.85
2025-06 2 5 14 95.48
2025-07 1 3 9 44.60
2025-08 1 2 3 48.27
2025-09 1 2 13 32.76
2025-10 2 5 13 97.20
2025-11 1 3 6 31.70
2025-12 2 5 7 64.58
2026-01 2 4 6 75.10
2026-02 2 3 12 49.33

2. The best month is October 2025 with €97.20, followed very closely by June 2025 with €95.48. The worst is November 2025 with €31.70, a month with a single order. With volumes this small, a good or bad month depends on whether a big order happened to land, and that's an analytical lesson in itself: with 20 orders you can't talk about seasonality.

3. The sum must give €727.95 because each of the 47 lines belongs to exactly one order, each order has exactly one date, and each date belongs to exactly one month. The groups are a partition of the set of lines: they don't overlap and they leave nothing out. The check:

68.80 + 61.28 + 58.85 + 95.48 + 44.60 + 48.27
     + 32.76 + 97.20 + 31.70 + 64.58 + 75.10 + 49.33 = 727.95

And the line count: 5+5+5+5+3+2+2+5+3+5+4+3 = 47. Both reconcile.

This check —do the groups add up to the total?— is the best validation there is for an aggregate query, and it deserves to become a reflex. If it doesn't reconcile, either you've lost rows (an INNER JOIN discarding) or you've duplicated them (a JOIN multiplying).

Conclusion

GROUP BY is the clause that turns SQL into analysis:

  • It splits the rows into groups and applies the aggregate to each one, returning one row per distinct combination that exists in the data — never for combinations that don't.
  • The logical execution order is now complete: FROM/JOINWHERE (rows) → GROUP BY (groups) → HAVING (groups) → SELECT (aliases) → DISTINCTORDER BYLIMIT. From that follows why WHERE can't use aggregates and HAVING can.
  • The golden rule: every column in the SELECT is grouped or aggregated. PostgreSQL, SQL Server and Oracle demand it; MySQL with ONLY_FULL_GROUP_BY off and SQLite return an arbitrary value, and that's a trap, not a convenience.
  • You can group by one column, by several (one row per existing combination) and by an expression (EXTRACT, CASE, TO_CHAR), repeating it in full in the GROUP BY.
  • PostgreSQL accepts a SELECT alias in the GROUP BY as an extension, but only bare, with the real column winning in case of ambiguity, and never in HAVING. Repeating the expression is always safer.
  • NULLs form a group of their own: the 10 web-channel orders and the 7 walk-in customers appear as a row with *(null)*, and that row is valuable information.
  • GROUP BY with JOIN is the central pattern of analysis. You now have GreenStore's key figures: revenue by category (Food €256.27 · Drinks €195.28 · Natural cosmetics €156.32 · Sustainable home €88.58 · Personal hygiene €31.50), the customer ranking (Sofia €111.88 · Lucía €107.60) and the product top (oil €109.53 · matcha €88.00).
  • Empty groups don't exist for an INNER JOIN. Category 6 only appears with a LEFT JOIN from categories and COUNT(ol.id) instead of COUNT(*), which is what turns it into an honest 0 rather than a false 1.
  • The top N pattern is GROUP BY + ORDER BY aggregate DESC + LIMIT; the top N per group needs window functions (module 10), as does any calculation that must aggregate without collapsing the rows.
  • ROLLUP, CUBE and GROUPING SETS add subtotals and totals in a single pass: the ROLLUP of category and year gave 15 rows with the 5 subtotals and the grand total of €727.95.

In the module's last lesson, the HAVING clause, you'll close the circle. You already know how to form groups; now you'll learn to filter them: categories with more than N products, customers with more than one order, products above a certain volume. You'll see why HAVING can use aggregates and WHERE can't —section 2's diagram will tell you on its own—, why WHERE is always preferable when the condition can be evaluated row by row, and you'll finally get the table comparing the three places you can filter in SQL: ON, WHERE and HAVING, closing the thread 03-03 left open.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved