Up to now, every row of your results came from a row of the database. SELECT projected columns, WHERE discarded rows, JOIN combined tables: the granularity could multiply, but you were still working row by row. Aggregate functions break that correspondence. They take in many rows and return a single value: a total, a count, an average, a maximum.
It's the step that turns a query into a report. "How much have we billed?", "how many orders are pending?", "what's the average order value?" are questions no previous query could answer. In this lesson you'll learn the five fundamental functions, you'll see why they all ignore NULLs except COUNT(*) —and why that sometimes suits you and sometimes deceives you—, you'll discover that SUM over an empty set returns NULL while COUNT returns 0, and you'll finally resolve the warning module 3 repeated three times: summing the shipping costs after joining with the detail inflates the total. You'll see the wrong number, the right number and the two correct ways of framing it.
Contents
- What an aggregate function is
- The five functions and their return types
- The three forms of
COUNT SUMandAVGover the sales detailAVGignoresNULLs: two correct answers to different questionsMINandMAXover numbers, dates and text- The empty set:
SUMgivesNULL,COUNTgives0 - An aggregate collapses the whole table, and can't be mixed with a bare column
FILTER (WHERE ...): aggregating only part of itSTRING_AGGandARRAY_AGG: non-numeric aggregates- Resolving module 3's warning
- Common Mistakes and Tips
- Exercises
- Conclusion
- What an aggregate function is
An aggregate function walks a set of rows and produces a single value.
flowchart LR
subgraph E["Input: 47 rows of order_lines"]
A["23.90"]
B["11.70"]
C["6.50"]
D["…"]
end
E --> F["SUM( )"]
F --> G["727.95<br/>1 row, 1 value"]
The simplest possible query with an aggregate:
| total_lines |
|---|
| 47 |
A single row, even though the table has 47. That collapse is the operation's defining feature, and every rule in the lesson comes out of it.
Two observations worth fixing from the start:
- An aggregate with no
GROUP BYcollapses the whole table into one row. It always returns exactly one, even if the table is empty. - Aggregate functions can't be used in the
WHERE. TheWHEREdecides which rows go into the set, so it can't depend on a calculation made over that very set. For filtering by an aggregate there'sHAVING, and that's lesson 04-06.
- The five functions and their return types
| Function | What it returns | Types it accepts | Does it ignore NULL? |
Empty set |
|---|---|---|---|---|
COUNT(*) |
Number of rows | — | Not applicable | 0 |
COUNT(expr) |
Number of non-null values | Any | Yes | 0 |
SUM(expr) |
Sum | Numeric, INTERVAL |
Yes | NULL |
AVG(expr) |
Arithmetic mean | Numeric, INTERVAL |
Yes | NULL |
MIN(expr) |
Minimum value | Any orderable type | Yes | NULL |
MAX(expr) |
Maximum value | Any orderable type | Yes | NULL |
And the return types in PostgreSQL, which matter more than they look:
| Input expression | COUNT |
SUM |
AVG |
MIN / MAX |
|---|---|---|---|---|
SMALLINT / INTEGER |
BIGINT |
BIGINT |
NUMERIC |
same type |
BIGINT |
BIGINT |
NUMERIC |
NUMERIC |
BIGINT |
NUMERIC |
BIGINT |
NUMERIC |
NUMERIC |
NUMERIC |
REAL / DOUBLE |
BIGINT |
DOUBLE |
DOUBLE |
same type |
DATE, TEXT, BOOLEAN |
BIGINT |
— | — | same type |
Two details with practical consequences:
SUMof integers returnsBIGINT, notINTEGER. It's a protection against overflow: adding up a million large integers overflowsINTEGEReasily.AVGof an integer returnsNUMERIC, not an integer. PostgreSQL doesn't truncate. You'll see it in section 6, and it's an important difference from other engines.
- The three forms of
COUNT
COUNTCOUNT has three spellings that almost never give the same number, and confusing them is one of the most common sources of error in reports.
| Spelling | Counts |
|---|---|
COUNT(*) |
Rows. All of them, whatever their values |
COUNT(column) |
Non-null values of that column |
COUNT(DISTINCT column) |
Distinct, non-null values of that column |
The best possible demonstration is in orders.employee_id, which has 20 rows, 10 values and 3 distinct sales reps:
SELECT COUNT(*) AS rows_,
COUNT(employee_id) AS with_rep,
COUNT(DISTINCT employee_id) AS distinct_reps
FROM orders;| rows_ | with_rep | distinct_reps |
|---|---|---|
| 20 | 10 | 3 |
20, 10 and 3. Three numbers over the same column of the same table, and all three are correct because they answer different questions:
- 20: how many orders are there? Every order exists, with or without a sales rep.
- 10: how many orders were handled by a sales rep? The ten from the phone channel. The web channel's
NULLs aren't counted. - 3: how many sales reps have handled any order? Óscar (4), Laia (5) and Marc (6). The other five employees never appear.
This is the definitive proof that aggregates ignore NULLs: COUNT(*) is the only form that counts the web channel's ten rows, because it's the only one that doesn't look at any value.
flowchart TD
A["20 rows of orders"] --> B["COUNT(*)<br/>counts rows<br/>→ 20"]
A --> C["COUNT(employee_id)<br/>discards the 10 NULLs<br/>→ 10"]
A --> D["COUNT(DISTINCT employee_id)<br/>discards NULLs and duplicates<br/>→ 3"]
When to use each one
| Business question | Correct spelling |
|---|---|
| "How many orders have we received?" | COUNT(*) |
| "How many orders have a sales rep assigned?" | COUNT(employee_id) |
| "How many sales reps are active in sales?" | COUNT(DISTINCT employee_id) |
| "How many distinct customers have bought?" | COUNT(DISTINCT customer_id) |
That last one is worth seeing, because it connects with 02-04's DISTINCT and with 03-02's disappearing customers:
| orders | buying_customers |
|---|---|
| 20 | 12 |
12 of the 15 customers have bought at some point. The three missing ones are Núria, Hugo and Inés, exactly the ones you recovered with 03-03's anti-join. And notice something important: COUNT(DISTINCT customer_id) over orders can't tell you there are 15 in total, because in orders they don't exist. For that you have to start from customers.
Performance warning:
COUNT(DISTINCT column)is noticeably more expensive thanCOUNT(column), because it forces the engine to sort or to build a hash table with all the values. With 20 rows it's irrelevant; with a hundred million, it's the difference between a second and several minutes. Use it when you need it, not out of habit.
Dialect note:
COUNT(DISTINCT a, b)with several columns works in MySQL but not in PostgreSQL, where you have to writeCOUNT(DISTINCT (a, b))using row-constructor syntax. AndCOUNT(*)versusCOUNT(1): in PostgreSQL they're identical in performance and both are optimised the same way, so the choice is purely stylistic. The course usesCOUNT(*).
SUM and AVG over the sales detail
SUM and AVG over the sales detailNow the question the business really asks: how much have we billed? A line's amount is the usual one, and it's calculated with ol.unit_price (03-02's rule), never with p.price.
SELECT COUNT(*) AS lines_,
SUM(ol.quantity) AS units,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue,
ROUND(AVG(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS avg_line_amount
FROM order_lines AS ol;| lines_ | units | revenue | avg_line_amount |
|---|---|---|---|
| 47 | 113 | 727.95 | 15.49 |
These are GreenStore's master figures: 47 lines, 113 units sold and €727.95 of product revenue (not counting shipping costs, which we'll see in section 11).
Why you aggregate over unit_price and not over products.price
This is the moment to check with numbers what 03-02 announced. Lines 1 and 4 carry the historical price, from before the April 2025 price rise:
SELECT ol.id,
p.name AS product,
ol.quantity,
ol.unit_price AS price_charged,
p.price AS current_price,
ROUND(ol.quantity * ol.unit_price, 2) AS actual_amount,
ROUND(ol.quantity * p.price, 2) AS amount_at_current_price
FROM order_lines AS ol
JOIN products AS p ON ol.product_id = p.id
WHERE ol.unit_price <> p.price
ORDER BY ol.id;| id | product | quantity | price_charged | current_price | actual_amount | amount_at_current_price |
|---|---|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 2 | 11.95 | 12.50 | 23.90 | 25.00 |
| 4 | Aloe vera face cream 50 ml | 1 | 17.50 | 18.90 | 17.50 | 18.90 |
2 lines out of 47. The accumulated difference is €2.50, small change in this data set. But look at what it means: if you aggregated over p.price, you'd be stating that in March 2025 you billed €2.50 that never came through the till. With a real catalogue and several price rises a year, that figure runs into the thousands.
The course's rule, now with aggregates:
SUMof sales always overol.unit_price.products.priceis for answering "how much does it cost today?", never "how much did we bill back then?".
Summing and counting over a subset
Aggregates combine with WHERE perfectly naturally, and the WHERE acts first:
SELECT COUNT(*) AS lines_2026,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue_2026
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
WHERE o.order_date >= DATE '2026-01-01'
AND o.order_date < DATE '2027-01-01';| lines_2026 | revenue_2026 |
|---|---|
| 7 | 124.43 |
€124.43 across the four orders of 2026, against €603.52 across the sixteen of 2025. They add up to the €727.95 total, as they should.
AVG ignores NULLs: two correct answers to different questions
AVG ignores NULLs: two correct answers to different questionsThat AVG ignores nulls looks like a technical detail. It's a business decision in disguise, and it's worth seeing with a case where the difference hurts.
The question: "what's the average salary of the sales rep who handles our orders?"
SELECT COUNT(*) AS orders,
COUNT(e.salary) AS orders_with_rep,
SUM(e.salary) AS salary_sum,
AVG(e.salary) AS avg_result
FROM orders AS o
LEFT JOIN employees AS e ON o.employee_id = e.id;| orders | orders_with_rep | salary_sum | avg_result |
|---|---|---|---|
| 20 | 10 | 274200.00 | 27420.000000000000 |
AVG has returned €27,420: it added up €274,200 and divided by 10, not by 20, because the ten web orders have e.salary at NULL and AVG ignores them.
Now the other reading of the same question:
SELECT ROUND(SUM(e.salary) / COUNT(*), 2) AS avg_over_all_rows
FROM orders AS o
LEFT JOIN employees AS e ON o.employee_id = e.id;| avg_over_all_rows |
|---|
| 13710.00 |
€13,710, exactly half. And both figures are correct:
| Figure | Divides by | Answers |
|---|---|---|
| €27,420 | 10 (the non-null values) | "Of the orders that have a sales rep, what's their average salary?" |
| €13,710 | 20 (all the rows) | "For every order that comes in, how much sales-rep salary sits behind it on average?" |
The second makes sense if you're spreading the sales cost across the whole order volume, including the ones that consume nobody's time. The first, if you're comparing sales-rep profiles. Choosing wrong doesn't raise an error: it gives a figure that doubles or halves reality.
flowchart TD
A["AVG(column)"] --> B["SUM of the NON-null<br/>values"]
A --> C["divided by<br/>COUNT(column)"]
D["Do you want the NULLs<br/>as zeros?"] -->|"Yes"| E["SUM(col) / COUNT(*)<br/>or AVG(COALESCE(col, 0))"]
D -->|"No"| F["AVG(col) as it is"]
The explicit way of treating nulls as zeros is AVG(COALESCE(e.salary, 0)), which would return the same €13,710. COALESCE is studied in 06-04; mention it to yourself every time you write an AVG over a nullable column.
The question to always ask yourself before writing
AVG: "is the average over the rows that have data, or over all the rows?". If you can't answer it, the query isn't defined yet.
MIN and MAX over numbers, dates and text
MIN and MAX over numbers, dates and textMIN and MAX work over any type that can be ordered, not just numbers. It's their most underused feature.
SELECT MIN(price) AS min_price,
MAX(price) AS max_price,
MIN(added_date) AS first_added,
MAX(added_date) AS last_added,
MIN(name) AS first_alphabetically,
MAX(name) AS last_alphabetically
FROM products;| min_price | max_price | first_added | last_added | first_alphabetically | last_alphabetically |
|---|---|---|---|---|---|
| 1.95 | 22.00 | 2025-01-15 | 2025-06-01 | Almond body oil 200 ml | Spirulina capsules 120 units |
Four data types in one query: NUMERIC, DATE and TEXT. Over text, MIN/MAX use the database's collation (02-05), so the result can vary between differently configured servers.
The most frequent use in practice is over dates, to bound the history:
SELECT COUNT(*) AS orders,
MIN(order_date) AS first_order,
MAX(order_date) AS last_order,
MIN(shipping_cost) AS min_shipping,
MAX(shipping_cost) AS max_shipping
FROM orders;| orders | first_order | last_order | min_shipping | max_shipping |
|---|---|---|---|---|
| 20 | 2025-03-04 | 2026-02-21 | 0.00 | 12.50 |
Eleven and a half months of history, from 4 March 2025 to 21 February 2026, with shipping between €0.00 (free delivery) and €12.50.
AVG's return type and why so many decimals come out
SELECT COUNT(*) AS reviews,
MIN(rating) AS worst,
MAX(rating) AS best,
AVG(rating) AS raw_avg
FROM reviews;| reviews | worst | best | raw_avg |
|---|---|---|---|
| 12 | 2 | 5 | 4.0833333333333333 |
The worst rating in all of GreenStore is a 2, the ginger kombucha's ("far too much ginger for my taste"). And look at the average: sixteen decimals. It happens because AVG over a SMALLINT column returns NUMERIC, and dividing two NUMERICs in PostgreSQL is computed with at least 16 significant digits so as not to lose precision. It isn't a bug: it's the guarantee that the engine hasn't rounded on its own initiative.
The way to present it is the usual one: compute at full precision and round only when displaying.
| reviews | avg_rating |
|---|---|
| 12 | 4.08 |
Dialect note: this is one of the points where engines diverge the most, and where the most silent errors happen when porting code.
Engine AVGover an integer columnResult with 49/12 PostgreSQL NUMERICat full precision4.0833333333333333MySQL DECIMAL4.0833SQLite Always REAL(floating point)4.083333333333333SQL Server INT: it truncates4Oracle NUMBER4.08333333333333…SQL Server is the dangerous case:
AVGof anINTcolumn does integer division and returns4. To get the decimal you have to cast explicitly:AVG(CAST(rating AS DECIMAL(10,2))). A report ported from PostgreSQL to SQL Server can start rounding down with nobody noticing.
- The empty set:
SUM gives NULL, COUNT gives 0
SUM gives NULL, COUNT gives 0This is a classic reporting trap, and GreenStore has the perfect case: category 6 (Supplements) has a single product, number 20 (Spirulina capsules), which is discontinued and has never been sold.
SELECT COUNT(*) AS lines_,
SUM(ol.quantity) AS units,
SUM(ol.quantity * ol.unit_price * (1 - ol.discount)) AS revenue,
AVG(ol.quantity * ol.unit_price * (1 - ol.discount)) AS avg_amount,
MAX(ol.quantity) AS max_quantity
FROM order_lines AS ol
JOIN products AS p ON ol.product_id = p.id
WHERE p.category_id = 6;| lines_ | units | revenue | avg_amount | max_quantity |
|---|---|---|---|---|
| 0 | (null) | (null) | (null) | (null) |
A single row, with one 0 and four nulls. Two things to learn from this:
- A query with aggregates and no
GROUP BYalways returns one row, even if theWHERElets none through. It doesn't return "0 rows": it returns one row with the result of aggregating nothing. COUNTof nothing is0;SUM,AVG,MINandMAXof nothing areNULL. It's consistent: counting zero elements gives zero, but the sum of an empty set has no natural value to return, and its average even less so.
Why it matters: if your report computes revenue * 1.21 to add the VAT, that cell will show NULL, not 0.00. And if the application consuming the result expects a number, it may fail. The solution is COALESCE(SUM(...), 0), which you'll see in 06-04.
| Aggregate | Empty set | All values NULL |
|---|---|---|
COUNT(*) |
0 |
n (it counts rows) |
COUNT(expr) |
0 |
0 |
SUM(expr) |
NULL |
NULL |
AVG(expr) |
NULL |
NULL |
MIN / MAX |
NULL |
NULL |
Notice the right-hand column: it's the same behaviour. For SUM and AVG it makes no difference whether there are no rows at all or there are rows with every value null. It's consistent with section 3's rule: aggregates discard nulls before operating, so "all null" and "empty" end up being the same set.
- An aggregate collapses the whole table, and can't be mixed with a bare column
You've already seen it: with no GROUP BY, the aggregate applies to all the rows coming out of the WHERE and produces one row. And from that comes the most important restriction of this lesson.
ERROR: column "p.name" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT p.name,
^The error is entirely right. Think about it: COUNT(*) is going to return a single row with the value 20. What should appear in that row's name column? "Extra virgin olive oil"? "Cold-pressed orange juice"? All twenty concatenated? The question has no answer, and PostgreSQL refuses to invent one.
The general rule, which will govern the whole of lesson 04-05:
Every column in the
SELECTmust be inside an aggregate function or be part of theGROUP BY. There's no third option.
There are three ways out here, and each answers a different question:
| What you want | How it's written |
|---|---|
| A single number for the whole table | SELECT COUNT(*) FROM products; |
| A number per category | Add GROUP BY category_id → lesson 04-05 |
| A specific name next to the total | Aggregate the name as well: MIN(p.name), or use window functions (module 10) |
This is exactly the doorway into GROUP BY. Most business questions aren't "how much have we billed?" but "how much have we billed per category?", and that requires splitting the table into groups before aggregating.
Dialect note: MySQL with the
ONLY_FULL_GROUP_BYmode switched off accepts that query without protest and returns an arbitrary value ofname. SQLite always does the same. It's a convenience that produces silently incorrect reports, and lesson 04-05 covers it in detail with a comparison table. Since MySQL 5.7.5 the mode is on by default, precisely because of this.
FILTER (WHERE ...): aggregating only part of it
FILTER (WHERE ...): aggregating only part of itYou often want several aggregates over different subsets in the same result row: total, delivered, cancelled. Writing three queries and stitching them together is tedious. PostgreSQL offers the FILTER clause:
SELECT COUNT(*) AS orders,
COUNT(*) FILTER (WHERE status = 'delivered') AS delivered,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
COUNT(*) FILTER (WHERE employee_id IS NULL) AS web_channel,
SUM(shipping_cost) AS shipping_total,
SUM(shipping_cost) FILTER (WHERE status = 'delivered') AS shipping_delivered
FROM orders;| orders | delivered | cancelled | web_channel | shipping_total | shipping_delivered |
|---|---|---|---|---|---|
| 20 | 14 | 1 | 10 | 118.25 | 76.05 |
Six metrics in a single pass over the table. The syntax is AGGREGATE(expr) FILTER (WHERE condition): the condition decides which rows go into that particular aggregate, without affecting the others or the query's general WHERE.
An example with money, comparing GreenStore's two trading years:
SELECT ROUND(SUM(amt.amount), 2) AS total,
ROUND(SUM(amt.amount) FILTER (WHERE amt.year_ = 2025), 2) AS sales_2025,
ROUND(SUM(amt.amount) FILTER (WHERE amt.year_ = 2026), 2) AS sales_2026,
COUNT(*) FILTER (WHERE amt.discount > 0) AS discounted_lines
FROM (
SELECT ol.quantity * ol.unit_price * (1 - ol.discount) AS amount,
ol.discount,
EXTRACT(YEAR FROM o.order_date) AS year_
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
) AS amt;| total | sales_2025 | sales_2026 | discounted_lines |
|---|---|---|---|
| 727.95 | 603.52 | 124.43 | 6 |
Only 6 of the 47 lines carry a discount. (EXTRACT is covered in depth in 06-03; here it just extracts the year. The subquery in the FROM is module 7: here it serves only to avoid repeating the amount expression three times.)
Portability:
FILTERis standard SQL but only PostgreSQL (9.4+) and SQLite (3.30+) implement it. In MySQL, SQL Server and Oracle you have to write the classic, portable form, which consists of putting aCASEinside the aggregate:COUNT(CASE WHEN status = 'delivered' THEN 1 END) AS delivered SUM(CASE WHEN status = 'delivered' THEN shipping_cost ELSE 0 END) AS shipping_deliveredIt works because
COUNTignores theNULLs aCASEwithoutELSEreturns. It's exactly section 3's mechanism, exploited on purpose.CASEis lesson 06-05.
STRING_AGG and ARRAY_AGG: non-numeric aggregates
STRING_AGG and ARRAY_AGG: non-numeric aggregatesNot every aggregate produces numbers. Two PostgreSQL ones are especially useful and worth knowing now, even though their natural ground is tomorrow's GROUP BY:
SELECT COUNT(*) AS reps,
STRING_AGG(name || ' ' || last_name, ', ' ORDER BY id) AS team,
ARRAY_AGG(id ORDER BY id) AS ids
FROM employees
WHERE job_title = 'Sales rep';| reps | team | ids |
|---|---|---|
| 2 | Óscar Peris Blasco, Laia Puig Sanchis | {4,5} |
STRING_AGG(expression, separator)concatenates the values of every row into a single string. The internalORDER BYis essential: without it, the concatenation order is arbitrary and the result isn't reproducible.ARRAY_AGG(expression)does the same but returns a PostgreSQL array, useful when the application is going to process the values separately.
Both ignore NULLs, like the rest.
Dialect note:
STRING_AGGexists in PostgreSQL and in SQL Server (2017+). MySQL and SQLite haveGROUP_CONCATwith a different syntax, and Oracle usesLISTAGG.ARRAY_AGGis PostgreSQL-specific, because it depends on the engine having native array types.
- Resolving module 3's warning
Now for the main course. Module 3 warned you three times —in 03-02, in 03-03 and in its conclusion— that summing a header value after joining with the detail inflates the result. Now you have the tools to see it, measure it and fix it.
The question: how much have we taken in total from shipping costs?
The wrong number
-- ⚠️ INCORRECT: it inflates the shipping costs
SELECT COUNT(*) AS rows_,
SUM(o.shipping_cost) AS shipping_total
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id;| rows_ | shipping_total |
|---|---|
| 47 | 278.70 |
The right number
-- ✅ CORRECT: aggregate over the header table alone
SELECT COUNT(*) AS rows_,
SUM(shipping_cost) AS shipping_total
FROM orders;| rows_ | shipping_total |
|---|---|
| 20 | 118.25 |
€278.70 against €118.25. The error is €160.45: the inflated figure is 2.36 times the real one, a factor very close to the average number of lines per order (47 / 20 = 2.35). It doesn't match exactly because the orders with the most lines aren't the ones paying the most shipping, but the order of magnitude of the error is always set by that ratio.
Why it happens, with order 1 in plain view
SELECT o.id AS order_id,
o.shipping_cost,
ol.id AS line_id,
ol.product_id
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
WHERE o.id = 1
ORDER BY ol.id;| order_id | shipping_cost | line_id | product_id |
|---|---|---|---|
| 1 | 4.95 | 1 | 1 |
| 1 | 4.95 | 2 | 2 |
| 1 | 4.95 | 3 | 14 |
The €4.95 GreenStore charged once appear on three rows, because the order has three lines. SUM doesn't know they're the same charge repeated: it adds up what it sees, €14.85 for this order. And there's no error, no warning: just a wrong number in a management report.
flowchart TD
A["orders<br/>20 rows · €118.25 of shipping"] --> B["JOIN order_lines"]
B --> C["47 rows<br/>each shipping_cost repeated<br/>as many times as it has lines"]
C --> D["SUM(o.shipping_cost)<br/>= €278.70<br/>❌ inflated"]
A --> E["SUM(shipping_cost)<br/>without joining the detail<br/>= €118.25<br/>✅ correct"]
The two correct ways of framing it
Way 1 (today's): aggregate each thing at its own level of granularity.
If the question is about headers, aggregate the header table. If it's about lines, aggregate the lines. Two separate queries, each at its natural granularity:
-- Product revenue: LINE level (47 rows)
SELECT ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS product_revenue
FROM order_lines;| product_revenue |
|---|
| 727.95 |
-- Shipping income: ORDER level (20 rows)
SELECT SUM(shipping_cost) AS shipping_revenue
FROM orders;| shipping_revenue |
|---|
| 118.25 |
Total billed by GreenStore: 727.95 + 118.25 = €846.20. This is the correct figure, and you get it by adding two aggregates computed separately, each over its own table.
Way 2 (module 7's): aggregate the detail first and join it afterwards.
When you need both figures in the same query —for instance, each order's total with its shipping— the correct technique consists of collapsing the detail to order level before joining it with the header. That requires a subquery or a CTE, which are modules 7 and 10. Here's the skeleton, so you recognise it when it arrives:
-- A preview of module 7: don't write it yet, just read it
SELECT o.id,
o.shipping_cost,
tot.line_amount,
o.shipping_cost + tot.line_amount AS order_total
FROM orders AS o
JOIN (SELECT order_id,
SUM(quantity * unit_price * (1 - discount)) AS line_amount
FROM order_lines
GROUP BY order_id) AS tot ON tot.order_id = o.id;The key idea: the subquery reduces the 47 lines to 20 rows, one per order. When you join it with orders there's no multiplication any more, and o.shipping_cost appears once per order. It's the general solution to the problem, and that's why 07-01 starts right here.
How to detect it yourself, every time
| Check | How |
|---|---|
| Count the rows before aggregating | If your FROM with JOINs returns 47 rows and you're summing a column from orders, you're summing it 47 times |
| Compare against the direct aggregate | SUM(shipping_cost) FROM orders is the reference truth. If your complex query doesn't match it, you have multiplication |
| Ask yourself what level each column lives at | shipping_cost lives on the order; quantity lives on the line. Summing them together requires bringing them to the same level first |
Use COUNT(DISTINCT o.id) |
If it's lower than COUNT(*), the headers are repeated |
That last check, applied here:
SELECT COUNT(*) AS rows_,
COUNT(DISTINCT o.id) AS actual_orders
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id;| rows_ | actual_orders |
|---|---|
| 47 | 20 |
47 ≠ 20: there's multiplication. As soon as you see that inequality, you know you can't sum any column from orders without correcting it.
Common Mistakes and Tips
- Summing a header column after joining with the detail. This lesson's mistake: €278.70 instead of €118.25. Aggregate each table at its own level.
- Confusing
COUNT(*),COUNT(column)andCOUNT(DISTINCT column). 20, 10 and 3 over the same column. Choose by the question, not out of habit. - Using
COUNT(column)believing it counts rows. It counts non-null values. If the column allows nulls, you're missing rows. - Forgetting that
AVGignores nulls. €27,420 against €13,710. Always ask yourself whether the average is over the rows with data or over all of them. - Expecting
0from aSUMwith no rows. It returnsNULL.COALESCE(SUM(...), 0)in 06-04. - Putting an aggregate in the
WHERE.ERROR: aggregate functions are not allowed in WHERE. It'sHAVING(04-06). - Mixing a bare column with an aggregate.
column ... must appear in the GROUP BY clause. That'sGROUP BY(04-05). - Calculating the amount with
p.priceinstead ofol.unit_price. It rewrites commercial history: €2.50 too much in GreenStore, thousands in a real catalogue. - Rounding before aggregating.
SUM(ROUND(x, 2))accumulates each rounding's error. Compute at full precision and round the final result. - Assuming
AVGof an integer returns decimals on every engine. SQL Server truncates. Cast explicitly if the code has to travel. - Tip: write the query without aggregating first and count the rows. If the count isn't what you expect, whatever aggregate you put on top will be wrong even if the syntax is perfect.
- Tip: validate every new figure against one you already know. €727.95 of revenue must reconcile with €603.52 from 2025 plus €124.43 from 2026. If it doesn't, there are rows too many or too few.
- Tip: use
FILTERto gather several metrics in a single pass. It's faster than firing off five queries and more readable than five nestedCASEs.
Exercises
Exercise 1
Management asks for a single-row dashboard with these six metrics over orders:
- Total number of orders.
- Number of distinct customers who have bought.
- Number of delivered orders.
- Total income from shipping costs.
- Date of the first and the last order.
- Number of web-channel orders (with no sales rep).
Write a single query. Then answer: why can't point 2 give you the 15 customers in the customers table?
Exercise 2
Over reviews, calculate the number of reviews, the average rating rounded to two decimals, the worst and the best rating, and how many distinct products have been reviewed.
Then answer these two questions and justify them with numbers:
- How many catalogue products have no review at all? Can you get it from this same query?
- If you calculated
AVG(rating)over aLEFT JOINofproductswithreviews, would the same average come out? Why?
Exercise 3
The finance director wants the total billed by GreenStore in 2025, shipping costs included. An intern hands him this:
-- ⚠️ INCORRECT
SELECT ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
+ SUM(o.shipping_cost), 2) AS total_2025
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
WHERE o.order_date >= DATE '2025-01-01'
AND o.order_date < DATE '2026-01-01';- Run the query and say what number it gives.
- Explain exactly which part is wrong and why.
- Calculate the correct number with the two separate queries it calls for.
- State how big the error is, in euros and as a percentage.
Solutions
Solution 1
SELECT COUNT(*) AS orders,
COUNT(DISTINCT customer_id) AS buying_customers,
COUNT(*) FILTER (WHERE status = 'delivered') AS delivered,
SUM(shipping_cost) AS shipping_revenue,
MIN(order_date) AS first_order,
MAX(order_date) AS last_order,
COUNT(*) FILTER (WHERE employee_id IS NULL) AS web_channel
FROM orders;| orders | buying_customers | delivered | shipping_revenue | first_order | last_order | web_channel |
|---|---|---|---|---|---|---|
| 20 | 12 | 14 | 118.25 | 2025-03-04 | 2026-02-21 | 10 |
Why point 2 gives 12 and not 15: because the query starts from orders, and in orders there's no row at all whose customer_id is 13, 14 or 15. Núria, Hugo and Inés have never bought, so there's nothing to count. COUNT(DISTINCT customer_id) answers "how many customers have bought?", not "how many customers do we have?". For the latter you have to ask customers:
| registered_customers |
|---|
| 15 |
It's the same lesson as 03-02: the table you start from determines which questions you can answer.
Solution 2
SELECT COUNT(*) AS reviews,
ROUND(AVG(rating), 2) AS avg_rating,
MIN(rating) AS worst,
MAX(rating) AS best,
COUNT(DISTINCT product_id) AS reviewed_products
FROM reviews;| reviews | avg_rating | worst | best | reviewed_products |
|---|---|---|---|---|
| 12 | 4.08 | 2 | 5 | 9 |
12 reviews over 9 distinct products: three products (the oil, the rice and the face cream) have two reviews each.
1. The products with no review are 11: the catalogue's 20 minus the 9 reviewed ones. But you can't get it from this query, because it starts from reviews and the products with no review don't appear there — it's literally 03-02's INNER JOIN problem. You have to ask products with an anti-join, as in solution 1 of 03-03:
SELECT COUNT(*) AS products_without_review
FROM products AS p
LEFT JOIN reviews AS r ON r.product_id = p.id
WHERE r.id IS NULL;| products_without_review |
|---|
| 11 |
9 + 11 = 20. It closes.
2. Yes, exactly the same average would come out: 4.08. And this is a surprising result. With products LEFT JOIN reviews you'd get 23 rows (the 12 reviews plus the 11 products with none), but in those 11 extra rows r.rating is NULL, and AVG ignores nulls. It still adds up to 49 and divides by 12.
SELECT COUNT(*) AS rows_,
COUNT(r.rating) AS ratings,
ROUND(AVG(r.rating), 2) AS avg_rating
FROM products AS p
LEFT JOIN reviews AS r ON r.product_id = p.id;| rows_ | ratings | avg_rating |
|---|---|---|
| 23 | 12 | 4.08 |
23 rows, 12 values, the same average. It's section 5 in its purest form: the LEFT JOIN changed the number of rows but not the set of aggregated values. If what you wanted was "the catalogue's average rating treating products with no review as a 0", you'd have to say so explicitly with COALESCE (06-04) — and it would be a fairly questionable metric.
Solution 3
1. What number it gives:
-- ⚠️ INCORRECT
SELECT ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount))
+ SUM(o.shipping_cost), 2) AS total_2025
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
WHERE o.order_date >= DATE '2025-01-01'
AND o.order_date < DATE '2026-01-01';| total_2025 |
|---|
| 822.57 |
2. What's wrong. The first SUM is correct: quantity, unit_price and discount live in order_lines, which is exactly the FROM's granularity. It adds up 2025's 40 lines and gives €603.52.
The second SUM is incorrect: shipping_cost lives in orders, and after the JOIN each order appears as many times as it has lines. 2025's 16 orders have become 40 rows, so their shipping has been counted 40 times instead of 16. Instead of €85.95 it gives €219.05.
3. The correct calculation, with two queries at their respective granularities:
-- Product: LINE level
SELECT COUNT(*) AS lines_,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS product_revenue_2025
FROM order_lines AS ol
JOIN orders AS o ON ol.order_id = o.id
WHERE o.order_date >= DATE '2025-01-01'
AND o.order_date < DATE '2026-01-01';| lines_ | product_revenue_2025 |
|---|---|
| 40 | 603.52 |
-- Shipping: ORDER level
SELECT COUNT(*) AS orders,
SUM(shipping_cost) AS shipping_2025
FROM orders
WHERE order_date >= DATE '2025-01-01'
AND order_date < DATE '2026-01-01';| orders | shipping_2025 |
|---|---|
| 16 | 85.95 |
Correct 2025 total: 603.52 + 85.95 = €689.47.
4. The error.
| Item | The intern's figure | Correct figure | Difference |
|---|---|---|---|
| Product revenue | 603.52 | 603.52 | 0.00 |
| Shipping costs | 219.05 | 85.95 | +133.10 |
| 2025 total | 822.57 | 689.47 | +133.10 |
€133.10 too much, 19.3 % over the correct total. And notice how dangerous the case is: the inflated figure isn't absurd —it isn't ten million, it's a plausible number nobody would question in a meeting. That's why module 3 insisted three times: this mistake isn't detected by reading the result, it's only detected by understanding the granularity.
Conclusion
You now know how to turn rows into figures:
- An aggregate function takes in many rows and returns a value. With no
GROUP BYit collapses the whole table into a single row, even if theWHERElets none through. - The five functions:
COUNT,SUM,AVG,MINandMAX.MINandMAXwork over any orderable type, dates and text included. - The three forms of
COUNTgive three different numbers over the same column:COUNT(*)= 20 orders,COUNT(employee_id)= 10 with a sales rep,COUNT(DISTINCT employee_id)= 3 sales reps. It's the best proof that aggregates ignoreNULLs and thatCOUNT(*)is the only exception. - GreenStore's master figures: 47 lines, 113 units, €727.95 of product revenue, €118.25 of shipping, €846.20 total. By year: €603.52 in 2025 and €124.43 in 2026.
AVGignores nulls, and that can be what you want or exactly the opposite: €27,420 dividing by 10 values, €13,710 dividing by 20 rows. Both figures are correct for different questions.- Over the empty set,
COUNTreturns0butSUM,AVG,MINandMAXreturnNULL. GreenStore's category 6 proves it. AVGreturnsNUMERICin PostgreSQL and that's why it shows sixteen decimals; SQL Server, by contrast, truncates the average of an integer column.- You can't mix a bare column with an aggregate:
column ... must appear in the GROUP BY clause. That error is the doorway into the next lesson. FILTER (WHERE ...)gathers several metrics in a single pass; its portable equivalent is aCASEinside the aggregate (06-05).STRING_AGGandARRAY_AGGaggregate text and arrays.- Module 3's warning is resolved:
SUM(o.shipping_cost)after joining withorder_linesgives €278.70 instead of €118.25, because each shipping charge is repeated as many times as the order has lines. The two solutions are to aggregate each table at its own level or to collapse the detail before joining (module 7).
In the next lesson, aggregating data with GROUP BY, you'll take the leap that turns all of this into genuine analysis. Instead of one figure for the whole company, you'll get one figure per group: sales by category, orders by status, customers by country, units by product. We'll finally extend the logical execution order diagram with GROUP BY and HAVING between WHERE and SELECT —we promised it in 02-01—, you'll see why NULLs form a group of their own, and you'll discover why an INNER JOIN hides category 6 while a LEFT JOIN with COUNT(column) shows it with an honest 0.
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
