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
GROUP BY: splitting into groups and aggregating each one- The logical execution order, extended
- The golden rule: grouped or aggregated
- Grouping by one column
- Grouping by several columns
- Grouping by an expression
GROUP BYand theSELECT's alias- Groups and
NULL GROUP BYwithJOIN: the central pattern of analysis- Empty groups: why category 6 doesn't show up
- Sorting by the aggregate and keeping the top N
ROLLUP,GROUPING SETSandCUBE- Common Mistakes and Tips
- Exercises
- Conclusion
GROUP BY: splitting into groups and aggregating each one
GROUP BY: splitting into groups and aggregating each oneThe 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:
| 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.
- 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.
- The golden rule: grouped or aggregated
It's a single sentence, and everything else follows from it:
Every expression in the
SELECTmust be (a) in theGROUP 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_BYoffYes 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_BYis on and don't turn it off.
- 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.
- 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.)
- 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:
- The expression appears twice, identically, in the
SELECTand in theGROUP BY. It's ugly and it's necessary, for the usual reason: the aliasprice_rangedoesn't exist yet when theGROUP BYruns. (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. - The numeric prefix on the labels (
1 ·,2 ·…) isn't decorative.ORDER BY price_rangesorts 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.
GROUP BY and the SELECT's alias
GROUP BY and the SELECT's aliasThis 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;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;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 theORDER BY, where they're standard and hold no surprises.
- Groups and
NULL
NULLIn 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.
GROUP BY with JOIN: the central pattern of analysis
GROUP BY with JOIN: the central pattern of analysisNow 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, andDISTINCTis how you undo it when counting.- Every non-aggregated column is in the
GROUP BY. Sincec.idiscustomers' PK, PostgreSQL would let us write justGROUP 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.
- 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 JOINanswers "how much has each category that has sold something sold?"; aLEFT JOINanswers "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.
- 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 BYends withp.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) DESCis 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 BYandLIMITaren't enough: you need a window function (ROW_NUMBER() OVER (PARTITION BY ...)), and that's module 10.
ROLLUP, GROUPING SETS and CUBE
ROLLUP, GROUPING SETS and CUBEA 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,CUBEandGROUPING SETSexist in PostgreSQL 9.5+, SQL Server and Oracle. MySQL only hasGROUP BY ... WITH ROLLUP(a different syntax and noCUBEorGROUPING SETS). SQLite has none of them: you have to emulate them withUNION ALL.
Common Mistakes and Tips
- Putting a column in the
SELECTthat'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 aGROUP BYover aLEFT 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 fromcategorieswith aLEFT JOIN. - Slipping an
INNER JOINin after theLEFT JOINin the same chain. It cancels theLEFTand 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 fromorder_lines. It counts lines. UseCOUNT(DISTINCT o.id). - Using a
SELECTalias in theHAVING.column "..." does not existin PostgreSQL. Repeat the aggregate. - Assuming
GROUP BYgenerates 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
statusthere should be at most 5 rows (theCHECK'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:
- How many rows does it return and why?
- What would have happened with an
INNER JOIN? - 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.
- 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.) - Identify the best month and the worst.
- 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 notCOUNT(p.id). After the secondLEFT 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, andDISTINCTis the correction.AVG(p.price)is affected too, and that one can't be fixed withDISTINCT. 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 overproducts, 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.95And 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/JOIN→WHERE(rows) →GROUP BY(groups) →HAVING(groups) →SELECT(aliases) →DISTINCT→ORDER BY→LIMIT. From that follows whyWHEREcan't use aggregates andHAVINGcan. - The golden rule: every column in the
SELECTis grouped or aggregated. PostgreSQL, SQL Server and Oracle demand it; MySQL withONLY_FULL_GROUP_BYoff 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 theGROUP BY. - PostgreSQL accepts a
SELECTalias in theGROUP BYas an extension, but only bare, with the real column winning in case of ambiguity, and never inHAVING. 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 BYwithJOINis 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 aLEFT JOINfromcategoriesandCOUNT(ol.id)instead ofCOUNT(*), which is what turns it into an honest0rather than a false1. - 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,CUBEandGROUPING SETSadd subtotals and totals in a single pass: theROLLUPof 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
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
