So far, every one of our queries has returned detail: one result row for each row in the database. That answers "what?" questions —which loans are open, which copies nobody has touched— but BiblioRed's management asks different ones: how many loans did we make last quarter?, which branch lends the most?, what is the star title?, how much have we collected in surcharges?, which members are over two loans?.
Those questions require moving from detail to summary: condensing many rows into a single figure. That is what the aggregate functions and the GROUP BY clause do, and it is the step that turns a database into a management tool.
This lesson also clears up something you have been dragging along since your very first query: the order in which an SQL statement is really executed. It is not the order in which it is written, and understanding it resolves half a dozen apparently inexplicable errors in one go.
We take the JOINs and subqueries from lesson 02-04 as read: we will use them without explaining them again.
Contents
- The aggregate functions
COUNTand its three formsSUM,AVG,MINandMAX- How aggregates treat
NULLvalues GROUP BY: aggregating by groups- The rule about what can appear in the
SELECT GROUP BYon several columnsHAVINGversusWHERE- The logical order of execution of a query
- Aggregation combined with
JOIN - The problem of rows inflated by a
JOIN COALESCEandCASE WHENinside aggregates- Introduction to window functions
- Common mistakes and tips
- Exercises
- Conclusion
- The aggregate functions
An aggregate function takes many values and returns just one. The five in the SQL standard, present in every manager:
| Function | What it returns | Types accepted |
|---|---|---|
COUNT |
Number of rows or of values | Any |
SUM |
Sum | Numeric |
AVG |
Arithmetic mean | Numeric |
MIN |
Minimum value | Numeric, text, dates |
MAX |
Maximum value | Numeric, text, dates |
Without GROUP BY, an aggregate function treats the whole table as a single group and returns one row only:
You can combine several in the same query, as long as they all summarize the same set of rows:
SELECT COUNT(*) AS total_loans,
MIN(loan_date) AS first_loan,
MAX(loan_date) AS last_loan,
SUM(surcharge) AS surcharges_collected
FROM loans;| total_loans | first_loan | last_loan | surcharges_collected |
|---|---|---|---|
| 12 | 2026-03-02 | 2026-07-25 | 7.00 |
Twelve loans between 2 March and 25 July 2026, with €7.00 collected in surcharges.
Notice an essential detail: the result has one row, not twelve. The query no longer talks about individual loans, it talks about the set. That is why you cannot freely mix aggregates with detail columns; we will see it in section 6.
COUNT and its three forms
COUNT and its three formsCOUNT looks like the simplest function and it is the one that causes the most confusion, because it has three variants that count different things.
SELECT COUNT(*) AS row_count,
COUNT(email) AS with_email,
COUNT(DISTINCT branch_id) AS distinct_branches
FROM members;| row_count | with_email | distinct_branches |
|---|---|---|
| 10 | 9 | 4 |
| Form | Counts | Result here |
|---|---|---|
COUNT(*) |
Rows, without looking at the content | 10 members |
COUNT(column) |
Non-null values of that column | 9 (Pau Miralles has no email) |
COUNT(DISTINCT column) |
Distinct non-null values | 4 different branches |
The difference between the first two is the one most often overlooked, and in loans it is especially telling:
SELECT COUNT(*) AS loan_count,
COUNT(return_date) AS closed_loans,
COUNT(*) - COUNT(return_date) AS open_loans,
COUNT(DISTINCT member_id) AS distinct_members
FROM loans;| loan_count | closed_loans | open_loans | distinct_members |
|---|---|---|---|
| 12 | 8 | 4 | 8 |
COUNT(return_date) counts 8 because the four open loans have that column at NULL. It is a very useful trick: counting the non-nulls of a column is the same as counting the rows that satisfy a certain condition, if that condition is reflected in the nullness.
And COUNT(DISTINCT member_id) returns 8, not 12: there are eight distinct members with loans, because some have more than one.
About performance: there is a legend that
COUNT(1)is faster thanCOUNT(*). It is false in PostgreSQL and in any modern manager: they are identical.COUNT(DISTINCT column)is noticeably more expensive, because it forces deduplication.
SUM, AVG, MIN and MAX
SUM, AVG, MIN and MAXSELECT COUNT(*) AS book_count,
MIN(publication_year) AS oldest,
MAX(publication_year) AS newest,
ROUND(AVG(publication_year), 2) AS average_year
FROM books;| book_count | oldest | newest | average_year |
|---|---|---|---|
| 9 | 1904 | 2023 | 2000.89 |
ROUND(expression, decimals) is not an aggregate function: it rounds the result. Without it, AVG over NUMERIC in PostgreSQL returns a number with a great many decimal places (2000.8888888888888889), hardly practical for a report.
MIN and MAX also work on text (alphabetical order) and on dates (chronological order):
SELECT MIN(last_name) AS first_alphabetically,
MAX(last_name) AS last_alphabetically,
MIN(join_date) AS oldest_member,
MAX(join_date) AS newest_member
FROM members;| first_alphabetically | last_alphabetically | oldest_member | newest_member |
|---|---|---|---|
| Alsina | Vendrell | 2018-01-22 | 2024-10-01 |
Watch out for a classic trap: this query tells you what the minimum last name is and what the minimum date is, but it does not tell you that they belong to the same person. Each aggregate is computed separately. To get the complete row of the longest-standing member you need something else:
SELECT member_id, first_name, last_name, join_date
FROM members
WHERE join_date = (SELECT MIN(join_date) FROM members);| member_id | first_name | last_name | join_date |
|---|---|---|---|
| 11 | Álvaro | Ferrán | 2018-01-22 |
It is the scalar subquery from the previous lesson, now with an aggregate inside. In section 13 we will see an alternative using window functions.
- How aggregates treat
NULL values
NULL valuesHere there is one rule and one exception, and both are worth burning in:
Every aggregate function ignores
NULLs. The only exception isCOUNT(*), which counts rows and does not look at the content.
Let's see it with the surcharge column, which has eight values and four NULLs:
SELECT COUNT(*) AS row_count,
COUNT(surcharge) AS with_surcharge,
SUM(surcharge) AS total,
ROUND(AVG(surcharge), 4) AS avg_ignoring_nulls,
ROUND(SUM(surcharge) / COUNT(*), 4) AS avg_counting_nulls_as_zero,
MIN(surcharge) AS minimum,
MAX(surcharge) AS maximum
FROM loans;| row_count | with_surcharge | total | avg_ignoring_nulls | avg_counting_nulls_as_zero | minimum | maximum |
|---|---|---|---|---|---|---|
| 12 | 8 | 7.00 | 0.8750 | 0.5833 | 0.00 | 4.20 |
Two different averages for the same data, and neither of them is wrong: they mean different things.
AVG(surcharge)= 7.00 / 8 = €0.875. It is "the average surcharge of the loans already closed", because the open ones have no surcharge computed yet.SUM(surcharge) / COUNT(*)= 7.00 / 12 = €0.583. It is "the average surcharge per loan made", treating the pending ones as zero.
The question you must always ask yourself is: what does the NULL mean in this column? If it means "not known yet", ignoring it is correct. If it means "zero", ignoring it distorts the average. In section 12 we will see COALESCE, the tool for explicitly turning NULL into zero when that is the correct semantics.
One extreme case that comes as a surprise:
SELECT SUM(surcharge), COUNT(surcharge), AVG(surcharge)
FROM loans
WHERE return_date IS NULL; -- the four open loans| sum | count | avg |
|---|---|---|
| (NULL) | 0 | (NULL) |
SUM over a set where every value is NULL returns NULL, not zero. And the same if the set is empty. COUNT, by contrast, returns 0: it is the only aggregate function that never returns NULL.
GROUP BY: aggregating by groups
GROUP BY: aggregating by groupsUntil now we summarized the whole table. GROUP BY splits it into groups and computes the aggregates within each group, returning one row per group.
| branch_id | copy_count |
|---|---|
| 1 | 6 |
| 2 | 4 |
| 3 | 3 |
| 4 | 2 |
Four rows, one per branch, and the counts add up to 15: the total number of copies. Conceptually this is what happens:
flowchart LR
T["copies<br/>15 rows"] --> G1["branch_id = 1<br/>6 rows"]
T --> G2["branch_id = 2<br/>4 rows"]
T --> G3["branch_id = 3<br/>3 rows"]
T --> G4["branch_id = 4<br/>2 rows"]
G1 --> R["Result<br/>4 rows,<br/>one per group"]
G2 --> R
G3 --> R
G4 --> R
Another example, grouping by a text column:
| status | how_many |
|---|---|
| available | 9 |
| on_loan | 4 |
| in_repair | 1 |
| withdrawn | 1 |
Notice that you can sort by the aggregate using its alias. And notice too that the groups come out of the data that exists: if no copy were in repair, that row simply would not appear. GROUP BY never invents empty groups.
A third example, with more than one aggregate per group:
SELECT publisher,
COUNT(*) AS titles,
MIN(publication_year) AS oldest,
MAX(publication_year) AS newest
FROM books
GROUP BY publisher
ORDER BY titles DESC, publisher;| publisher | titles | oldest | newest |
|---|---|---|---|
| Andana Press | 3 | 1989 | 2021 |
| Marlia Editions | 3 | 2012 | 2017 |
| North Technical Press | 2 | 2019 | 2023 |
| Vallmar City Council | 1 | 1904 | 1904 |
- The rule about what can appear in the
SELECT
SELECTThis is the rule that produces the most errors when starting out:
Every column that appears in the
SELECTand is not inside an aggregate function must be listed in theGROUP BY.
The reason is plain common sense. If you group the copies by branch, each result row represents six different copies (the ones at branch 1). Which code should it show? Which of the six? The question has no answer, so the manager rejects it:
The three legitimate ways to fix it, depending on what you really want:
-- a) Add the column to the GROUP BY: the groups change (and here it no longer
-- groups anything, because code is unique)
SELECT branch_id, code, COUNT(*) FROM copies GROUP BY branch_id, code;
-- b) Wrap it in an aggregate: "the smallest code at each branch"
SELECT branch_id, MIN(code) AS first_code, COUNT(*) AS copy_count
FROM copies GROUP BY branch_id ORDER BY branch_id;
-- c) Remove it from the SELECT
SELECT branch_id, COUNT(*) FROM copies GROUP BY branch_id;Result of option b):
| branch_id | first_code | copy_count |
|---|---|---|
| 1 | EJ-3082 | 6 |
| 2 | EJ-3081 | 4 |
| 3 | EJ-3083 | 3 |
| 4 | EJ-3088 | 2 |
The functional-dependency exception
PostgreSQL, since version 9.1, allows a very practical relaxation: if you group by a table's primary key, you can select any other column of that same table without listing it, because the primary key determines it uniquely.
-- Legal in PostgreSQL: members.last_name functionally depends on members.member_id
SELECT m.member_id, m.first_name, m.last_name, COUNT(l.loan_id) AS loan_count
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
GROUP BY m.member_id
ORDER BY m.member_id;It is a real convenience, but it is not portable: in other managers you would have to write GROUP BY m.member_id, m.first_name, m.last_name. If your SQL has to work on several engines, list every column.
And SQLite's danger
SQLite does not apply this rule at all. The query that raises an error in PostgreSQL runs in SQLite and returns any code from the group, chosen in an undocumented way. There is no error, there is no warning, and the report is wrong.
sqlite> SELECT branch_id, code, COUNT(*) FROM copies GROUP BY branch_id;
1|EJ-3095|6
2|EJ-3090|4
...That EJ-3095 represents nothing. It is one of the most dangerous differences between the two managers: SQLite's permissiveness turns an error into a false piece of data. Write your SQL as if PostgreSQL were watching you, even when you are in SQLite.
GROUP BY on several columns
GROUP BY on several columnsWhen you group by two columns, the groups are the distinct combinations of both:
SELECT branch_id, status, COUNT(*) AS how_many
FROM copies
GROUP BY branch_id, status
ORDER BY branch_id, status;| branch_id | status | how_many |
|---|---|---|
| 1 | available | 4 |
| 1 | on_loan | 2 |
| 2 | available | 2 |
| 2 | on_loan | 2 |
| 3 | available | 2 |
| 3 | withdrawn | 1 |
| 4 | available | 1 |
| 4 | in_repair | 1 |
Eight groups, whose counts add up to 15. Just as before: only the combinations that exist appear. Branch 1 has no copy in repair, so that row is not there —it does not come out with a 0—. If you need the complete grid with zeros included, there are two routes: a CROSS JOIN that generates every combination (lesson 02-04) plus a LEFT JOIN against the data, or the CASE WHEN tabulation of section 12.
The order of the columns in the GROUP BY does not alter the groups (GROUP BY a, b and GROUP BY b, a produce the same ones), but it is worth making it match the ORDER BY so that the report reads well.
HAVING versus WHERE
HAVING versus WHEREWHERE filters rows, before grouping. HAVING filters groups, after grouping. The difference is one of timing, not of syntax.
Question: which members have two or more loans?
SELECT m.member_id,
m.first_name || ' ' || m.last_name AS member,
COUNT(*) AS loan_count
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
GROUP BY m.member_id, m.first_name, m.last_name
HAVING COUNT(*) >= 2
ORDER BY loan_count DESC, member;| member_id | member | loan_count |
|---|---|---|
| 14 | Marta Alsina | 3 |
| 15 | Iván Pereda | 2 |
| 16 | Nuria Bastos | 2 |
Trying the same thing with WHERE is impossible:
SELECT m.member_id, COUNT(*) FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
WHERE COUNT(*) >= 2
GROUP BY m.member_id;And it makes sense: when the WHERE is evaluated, the groups do not exist yet, so there is nothing to count.
Using both at once
That is the usual case, and each does its own job:
-- Rows: only loans with a surcharge. Groups: only members over €1.
SELECT m.member_id,
m.last_name,
COUNT(*) AS loans_with_surcharge,
SUM(l.surcharge) AS total_surcharge
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
WHERE l.surcharge > 0
GROUP BY m.member_id, m.last_name
HAVING SUM(l.surcharge) > 1.00
ORDER BY total_surcharge DESC;| member_id | last_name | loans_with_surcharge | total_surcharge |
|---|---|---|---|
| 12 | Quiroga | 1 | 4.20 |
| 15 | Pereda | 1 | 1.40 |
| 18 | Vendrell | 1 | 1.40 |
| Clause | What it filters | When it acts | Does it accept aggregates? |
|---|---|---|---|
WHERE |
Individual rows | Before GROUP BY |
No |
HAVING |
Groups already formed | After GROUP BY |
Yes |
Practical efficiency rule: if a condition can be expressed in WHERE, put it in WHERE. Filtering before grouping means grouping fewer rows. Putting a condition that uses no aggregates into HAVING (HAVING m.branch_id = 2) works in many managers, but it is slower and more confusing.
- The logical order of execution of a query
Now everything falls into place. An SQL query is not evaluated in the order in which it is written. The logical order is this:
flowchart TD
A["1. FROM<br/>takes the starting tables"] --> B["2. JOIN ... ON<br/>matches rows"]
B --> C["3. WHERE<br/>discards individual rows"]
C --> D["4. GROUP BY<br/>splits the result into groups"]
D --> E["5. HAVING<br/>discards whole groups"]
E --> F["6. SELECT<br/>computes columns and aggregates,<br/>applies the aliases"]
F --> G["7. DISTINCT<br/>removes repeated rows"]
G --> H["8. ORDER BY<br/>sorts the result"]
H --> I["9. LIMIT / OFFSET<br/>trims"]
This diagram explains at a glance four behaviors that until now looked like whims:
WHEREcannot use aggregates. It runs at step 3; the groups are formed at step 4.WHEREcannot use theSELECTaliases. TheSELECTis step 6, which comes later.WHERE loan_count >= 2raises a nonexistent-column error.ORDER BYcan use theSELECTaliases. It is step 8, after step 6. That is whyORDER BY loan_count DESCworked.LIMITcomes last. It trims the final result, not the rows read:LIMIT 3in a query withGROUP BYreturns three groups, not three rows of the table.
-- ERROR: 'loan_count' is an alias defined at step 6, and WHERE is step 3
SELECT m.member_id, COUNT(*) AS loan_count
FROM loans l JOIN members m ON m.member_id = l.member_id
WHERE loan_count >= 2
GROUP BY m.member_id;We insist on the word logical: it is the order in which you have to reason about the query. The optimizer (lesson 01-04) is free to execute things in a different physical order as long as the result is the same.
- Aggregation combined with
JOIN
JOINThis is where the last two lessons come together and BiblioRed starts producing real reports.
Loans per branch
Note the nuance: a loan's branch is the branch of the copy lent, which need not be the member's home branch.
SELECT br.name AS branch,
COUNT(*) AS loan_count
FROM loans l
INNER JOIN copies c ON c.copy_id = l.copy_id
INNER JOIN branches br ON br.branch_id = c.branch_id
GROUP BY br.branch_id, br.name
ORDER BY loan_count DESC;| branch | loan_count |
|---|---|
| North | 6 |
| Central | 4 |
| South | 1 |
| East | 1 |
The North branch concentrates half the activity. They add up to 12 ✓.
The most borrowed books
SELECT b.title,
COUNT(*) AS times_lent
FROM loans l
INNER JOIN copies c ON c.copy_id = l.copy_id
INNER JOIN books b ON b.book_id = c.book_id
GROUP BY b.book_id, b.title
ORDER BY times_lent DESC, b.title
LIMIT 5;| title | times_lent |
|---|---|
| The Map of Time | 4 |
| The Pillars of the Earth | 3 |
| Algebra for the Impatient | 1 |
| Delta Trails | 1 |
| House of Tides | 1 |
Important: two books are missing from this listing. "Urban Gardening Handbook" and "Ensanche Records (1904)" have never been lent, and the INNER JOIN removes them before grouping. If the report has to include them with a 0, you have to combine LEFT JOIN with COUNT(column):
SELECT b.title,
COUNT(l.loan_id) AS times_lent
FROM books b
LEFT JOIN copies c ON c.book_id = b.book_id
LEFT JOIN loans l ON l.copy_id = c.copy_id
GROUP BY b.book_id, b.title
ORDER BY times_lent DESC, b.title;| title | times_lent |
|---|---|
| The Map of Time | 4 |
| The Pillars of the Earth | 3 |
| Algebra for the Impatient | 1 |
| Delta Trails | 1 |
| House of Tides | 1 |
| Ravenna Notebooks | 1 |
| Winter of the Birds | 1 |
| Ensanche Records (1904) | 0 |
| Urban Gardening Handbook | 0 |
There is a lesson here that is worth the price of admission on its own: COUNT(*) would have returned 1 instead of 0 for the last two, because the LEFT JOIN generates a row filled with NULL and COUNT(*) counts rows. COUNT(l.loan_id) counts non-null values, and there are none there.
With a
LEFT JOIN, neverCOUNT(*): alwaysCOUNT(column_from_the_right_table).
Loans per member, including those with none
SELECT m.member_id,
m.first_name || ' ' || m.last_name AS member,
COUNT(l.loan_id) AS loan_count,
MAX(l.loan_date) AS last_loan
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
GROUP BY m.member_id, m.first_name, m.last_name
ORDER BY loan_count DESC, member;| member_id | member | loan_count | last_loan |
|---|---|---|---|
| 14 | Marta Alsina | 3 | 2026-07-14 |
| 15 | Iván Pereda | 2 | 2026-07-18 |
| 16 | Nuria Bastos | 2 | 2026-05-04 |
| 11 | Álvaro Ferrán | 1 | 2026-05-08 |
| 17 | Diego Salom | 1 | 2026-07-21 |
| 18 | Lucía Vendrell | 1 | 2026-04-12 |
| 19 | Pau Miralles | 1 | 2026-07-25 |
| 12 | Sonia Quiroga | 1 | 2026-05-19 |
| 20 | Elena Roig | 0 | (NULL) |
| 13 | Ramón Etxebarri | 0 | (NULL) |
This is the complete member-activity report, with the inactive ones included. Compare it with the anti-join from the previous lesson: there we only got who had no loans; now we have the whole picture.
- The problem of rows inflated by a
JOIN
JOINWe pick up the exercise we left half-finished in lesson 02-04. A BiblioRed manager asks: "how many members and how many copies are there at each branch". The query that writes itself is this one:
SELECT br.name AS branch,
COUNT(m.member_id) AS member_count,
COUNT(c.copy_id) AS copy_count
FROM branches br
LEFT JOIN members m ON m.branch_id = br.branch_id
LEFT JOIN copies c ON c.branch_id = br.branch_id
GROUP BY br.branch_id, br.name
ORDER BY br.branch_id;| branch | member_count | copy_count |
|---|---|---|
| Central | 24 | 24 |
| North | 12 | 12 |
| South | 6 | 6 |
| East | 2 | 2 |
Every number is wrong, and the fact that the two columns are identical is the alarm signal. Central has 4 members and 6 copies, not 24 and 24.
The cause: members and copies are not related to each other; both hang off branches independently. When they are joined, a Cartesian product occurs within each branch: 4 members × 6 copies = 24 rows, and each COUNT counts those 24. It is the row explosion (fan trap).
The three solutions, from worst to best:
-- a) COUNT(DISTINCT ...): it works, and it is the quickest to write
SELECT br.name AS branch,
COUNT(DISTINCT m.member_id) AS member_count,
COUNT(DISTINCT c.copy_id) AS copy_count
FROM branches br
LEFT JOIN members m ON m.branch_id = br.branch_id
LEFT JOIN copies c ON c.branch_id = br.branch_id
GROUP BY br.branch_id, br.name
ORDER BY br.branch_id;| branch | member_count | copy_count |
|---|---|---|
| Central | 4 | 6 |
| North | 3 | 4 |
| South | 2 | 3 |
| East | 1 | 2 |
Correct. But COUNT(DISTINCT) is expensive and it only rescues the counts: a SUM would still be inflated, because it would add the same value several times. If instead of counting copies you were adding up amounts, the result would be false and DISTINCT would not fix it.
-- b) Scalar subqueries: each figure is computed separately
SELECT br.name AS branch,
(SELECT COUNT(*) FROM members m WHERE m.branch_id = br.branch_id) AS member_count,
(SELECT COUNT(*) FROM copies c WHERE c.branch_id = br.branch_id) AS copy_count
FROM branches br
ORDER BY br.branch_id;
-- c) Aggregate each branch separately and join afterwards: the canonical form
WITH members_per_branch AS (
SELECT branch_id, COUNT(*) AS member_count FROM members GROUP BY branch_id
),
copies_per_branch AS (
SELECT branch_id, COUNT(*) AS copy_count FROM copies GROUP BY branch_id
)
SELECT br.name AS branch,
COALESCE(mb.member_count, 0) AS member_count,
COALESCE(cb.copy_count, 0) AS copy_count
FROM branches br
LEFT JOIN members_per_branch mb ON mb.branch_id = br.branch_id
LEFT JOIN copies_per_branch cb ON cb.branch_id = br.branch_id
ORDER BY br.branch_id;All three return the correct table. Option c) is the one to internalize: when two independent branches have to be summarized, aggregate each one on its own and join the summaries. It is the only one that scales to any number of branches and to any aggregate, not just COUNT.
How to spot the problem in practice: if a total looks suspiciously large, strip out the aggregates and run the query with SELECT *. If far more rows come out than you expected, you have an explosion.
COALESCE and CASE WHEN inside aggregates
COALESCE and CASE WHEN inside aggregatesCOALESCE: replacing NULL with a value
COALESCE(a, b, c, ...) returns the first argument that is not NULL. It is the tool for explicitly deciding what to do with the absence of a value.
SELECT m.member_id,
m.last_name,
COUNT(l.loan_id) AS loan_count,
COALESCE(SUM(l.surcharge), 0) AS total_surcharge
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
GROUP BY m.member_id, m.last_name
ORDER BY total_surcharge DESC, m.member_id;| member_id | last_name | loan_count | total_surcharge |
|---|---|---|---|
| 12 | Quiroga | 1 | 4.20 |
| 15 | Pereda | 2 | 1.40 |
| 18 | Vendrell | 1 | 1.40 |
| 11 | Ferrán | 1 | 0.00 |
| 13 | Etxebarri | 0 | 0.00 |
| 14 | Alsina | 3 | 0.00 |
| 16 | Bastos | 2 | 0.00 |
| 17 | Salom | 1 | 0.00 |
| 19 | Miralles | 1 | 0.00 |
| 20 | Roig | 0 | 0.00 |
Without the COALESCE, four members would show NULL instead of 0.00: the two with no loans at all (Etxebarri and Roig) and the two whose only loan is still open and therefore has no surcharge computed (Salom and Miralles). A report with NULL in a money column is a report nobody knows how to read.
Notice the order in which it is applied: COALESCE wraps the aggregate, not the other way round. SUM(COALESCE(l.surcharge, 0)) would also work and would even be semantically more precise (it treats each individual NULL as zero), but for the total it makes no difference… except with AVG, where it does change the result, because it alters the number of values averaged. Check it:
SELECT ROUND(AVG(surcharge), 4) AS avg_ignoring_nulls, -- 0.8750
ROUND(AVG(COALESCE(surcharge, 0)), 4) AS avg_nulls_as_zero -- 0.5833
FROM loans;CASE WHEN: counting conditionally
CASE is SQL's conditional structure:
Placed inside a SUM or a COUNT, it lets you cross-tabulate: produce several columns that count different things about the same group. It is how you build a dashboard in a single query.
SELECT br.name AS branch,
COUNT(*) AS total,
SUM(CASE WHEN c.status = 'available' THEN 1 ELSE 0 END) AS available,
SUM(CASE WHEN c.status = 'on_loan' THEN 1 ELSE 0 END) AS on_loan,
SUM(CASE WHEN c.status IN ('in_repair','withdrawn') THEN 1 ELSE 0 END) AS out_of_service
FROM copies c
INNER JOIN branches br ON br.branch_id = c.branch_id
GROUP BY br.branch_id, br.name
ORDER BY br.name;| branch | total | available | on_loan | out_of_service |
|---|---|---|---|---|
| Central | 6 | 4 | 2 | 0 |
| East | 2 | 1 | 0 | 1 |
| North | 4 | 2 | 2 | 0 |
| South | 3 | 2 | 0 | 1 |
This is exactly the complete grid that GROUP BY branch_id, status could not give: here the zeros do appear, because the columns are fixed by the query and do not depend on the data.
How it works: for each row, the CASE produces 1 or 0, and SUM adds them up. A widely used alternative is COUNT(CASE WHEN condition THEN 1 END) —with no ELSE, so the rows that do not match give NULL and COUNT ignores them—.
PostgreSQL also offers a more elegant standard syntax, the FILTER clause:
SELECT br.name AS branch,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE c.status = 'available') AS available,
COUNT(*) FILTER (WHERE c.status = 'on_loan') AS on_loan
FROM copies c
INNER JOIN branches br ON br.branch_id = c.branch_id
GROUP BY br.branch_id, br.name
ORDER BY br.name;Same result, far more readable. FILTER does not exist in SQLite or MySQL; there you need CASE WHEN. And CASE WHEN works everywhere, so it is the safe option.
- Introduction to window functions
We finish with a capability that solves a problem GROUP BY cannot: computing an aggregate without losing the detail.
Notice the limitation. This query tells you how many loans each member has, but it loses the individual loans:
And what if you want to see each loan and, next to it, how many that member has in total? That is where window functions come in: they compute an aggregate over a set of related rows, but return one row for each original row.
SELECT m.last_name,
l.loan_id,
l.loan_date,
COUNT(*) OVER (PARTITION BY l.member_id) AS member_loans
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
ORDER BY m.last_name, l.loan_date;| last_name | loan_id | loan_date | member_loans |
|---|---|---|---|
| Alsina | 1 | 2026-03-02 | 3 |
| Alsina | 4 | 2026-04-06 | 3 |
| Alsina | 9 | 2026-07-14 | 3 |
| Bastos | 3 | 2026-03-11 | 2 |
| Bastos | 6 | 2026-05-04 | 2 |
| Ferrán | 7 | 2026-05-08 | 1 |
| Miralles | 12 | 2026-07-25 | 1 |
| Pereda | 2 | 2026-03-05 | 2 |
| Pereda | 10 | 2026-07-18 | 2 |
| Quiroga | 8 | 2026-05-19 | 1 |
| Salom | 11 | 2026-07-21 | 1 |
| Vendrell | 5 | 2026-04-12 | 1 |
Twelve rows, one per loan, each with its member's total repeated alongside. That is impossible with GROUP BY.
GROUP BY |
Window function (OVER) |
|
|---|---|---|
| Result rows | One per group | One per original row |
| Is the detail lost? | Yes | No |
| Syntax | COUNT(*) ... GROUP BY member_id |
COUNT(*) OVER (PARTITION BY member_id) |
| What it is for | Total reports | Comparing each row with its group, numbering, rankings |
PARTITION BY is to windows what GROUP BY is to aggregates: it defines the subsets. If you leave it out (OVER ()), the window is the whole table.
Numbering and ranking: ROW_NUMBER and RANK
SELECT m.last_name,
l.loan_date,
ROW_NUMBER() OVER (PARTITION BY l.member_id ORDER BY l.loan_date) AS loan_number
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
ORDER BY m.last_name, loan_number;| last_name | loan_date | loan_number |
|---|---|---|
| Alsina | 2026-03-02 | 1 |
| Alsina | 2026-04-06 | 2 |
| Alsina | 2026-07-14 | 3 |
| Bastos | 2026-03-11 | 1 |
| Bastos | 2026-05-04 | 2 |
| Ferrán | 2026-05-08 | 1 |
| Miralles | 2026-07-25 | 1 |
| Pereda | 2026-03-05 | 1 |
| Pereda | 2026-07-18 | 2 |
| Quiroga | 2026-05-19 | 1 |
| Salom | 2026-07-21 | 1 |
| Vendrell | 2026-04-12 | 1 |
"Loan number N of each member". This pattern —numbering within each group and keeping loan_number = 1— is the standard way to obtain "the most recent record of each X", a query that without windows is surprisingly awkward.
RANK is like ROW_NUMBER but it ties:
WITH counts AS (
SELECT b.book_id, b.title, COUNT(*) AS loan_count
FROM loans l
INNER JOIN copies c ON c.copy_id = l.copy_id
INNER JOIN books b ON b.book_id = c.book_id
GROUP BY b.book_id, b.title
)
SELECT title,
loan_count,
RANK() OVER (ORDER BY loan_count DESC) AS place,
DENSE_RANK() OVER (ORDER BY loan_count DESC) AS dense_place,
ROW_NUMBER() OVER (ORDER BY loan_count DESC, title) AS row_order
FROM counts
ORDER BY loan_count DESC, title;| title | loan_count | place | dense_place | row_order |
|---|---|---|---|---|
| The Map of Time | 4 | 1 | 1 | 1 |
| The Pillars of the Earth | 3 | 2 | 2 | 2 |
| Algebra for the Impatient | 1 | 3 | 3 | 3 |
| Delta Trails | 1 | 3 | 3 | 4 |
| House of Tides | 1 | 3 | 3 | 5 |
| Ravenna Notebooks | 1 | 3 | 3 | 6 |
| Winter of the Birds | 1 | 3 | 3 | 7 |
The three functions differ precisely on ties:
ROW_NUMBERnever ties: it numbers 1, 2, 3, 4, 5, 6, 7 even when the values are equal.RANKties and skips: the five books with one loan are all "place 3"; if there were a sixth distinct value, it would be place 8.DENSE_RANKties and does not skip: the next distinct value would be place 4.
We stop here. Window functions have a great deal more to offer —moving averages, running totals, LAG and LEAD to compare with the previous row, window frames with ROWS BETWEEN— and they are the bread and butter of data analysis. They are available in PostgreSQL since version 8.4 and in SQLite since 3.25.
Common Mistakes and Tips
- Leaving a column out of the
GROUP BY. In PostgreSQL it raises an error; in SQLite it runs and returns an arbitrary value, which is infinitely worse. Always write the completeGROUP BY. - Using
COUNT(*)with aLEFT JOIN. It returns 1 where it should return 0, because it counts theNULL-filled row. UseCOUNT(column_from_the_right_table). - Counting over a
JOINthat inflates rows. If two independent tables hang off a third, eachCOUNTcounts the Cartesian product. Aggregate each branch separately. - Putting a condition with an aggregate in
WHERE. Guaranteed error: aggregates go inHAVING. - Putting a condition that uses no aggregates in
HAVING. It works, but it filters later than necessary and confuses whoever reads it. It goes inWHERE. - Using a
SELECTalias inside theWHERE. TheSELECTis evaluated afterwards. InORDER BYyou can. - Assuming that
SUMover pureNULLs gives zero. It givesNULL. Wrap it inCOALESCE(SUM(x), 0)if the report needs a number. - Comparing
AVGwithout deciding what to do with theNULLs.AVG(x)andAVG(COALESCE(x, 0))give different figures and both can be correct: the question is what the absence means. - Believing that
MIN(a)andMAX(b)come from the same row. They do not. Each aggregate is computed on its own. - Tip: when an aggregate gives you a number that does not add up, remove the functions and run the query in detail mode with
SELECT *. Counting the rows by eye reveals explosions instantly. - Tip: always write the
GROUP BYstarting with the primary key of the table you are summarizing (GROUP BY b.book_id, b.title, not justGROUP BY b.title). If there were two books with the same title, grouping by the title would merge them into one.
Exercises
Exercise 1: Counts and totals
- How many members are there at each branch? Show the branch name, including the branches that have none.
- How many books are there per language?
- How many copies does each book have? Include the title and sort from most to fewest.
- What is the total surcharge collected at each branch (by the branch of the copy lent)? Show
0.00where there has been none.
Exercise 2: Grouping and filtering groups
- Which authors have more than one work in the collection?
- Which books have copies at three or more different branches?
- Which members have returned a book late, and how many times? Sort from most to fewest.
- Which branches have three or more available copies?
Exercise 3: Complete reports
- Build the loans-per-month table: year-month, number of loans and total surcharge. Hint: in PostgreSQL,
TO_CHAR(loan_date, 'YYYY-MM'); in SQLite,strftime('%Y-%m', loan_date). - For each member, show their name, the number of loans, the number of reservations and the accumulated surcharge, in a single query and with the correct figures. Watch out for the row explosion.
- Using window functions, show each loan with: the member, the date, the surcharge, and the running total surcharge for that member.
Solutions
Solution 1
-- 1
SELECT br.name AS branch, COUNT(m.member_id) AS member_count
FROM branches br
LEFT JOIN members m ON m.branch_id = br.branch_id
GROUP BY br.branch_id, br.name
ORDER BY member_count DESC, br.name;| branch | member_count |
|---|---|
| Central | 4 |
| North | 3 |
| South | 2 |
| East | 1 |
| language | book_count |
|---|---|
| es | 8 |
| ca | 1 |
-- 3
SELECT b.title, COUNT(c.copy_id) AS copy_count
FROM books b
LEFT JOIN copies c ON c.book_id = b.book_id
GROUP BY b.book_id, b.title
ORDER BY copy_count DESC, b.title;| title | copy_count |
|---|---|
| The Map of Time | 3 |
| Algebra for the Impatient | 2 |
| The Pillars of the Earth | 2 |
| Urban Gardening Handbook | 2 |
| Winter of the Birds | 2 |
| Delta Trails | 1 |
| Ensanche Records (1904) | 1 |
| House of Tides | 1 |
| Ravenna Notebooks | 1 |
-- 4
SELECT br.name AS branch, COALESCE(SUM(l.surcharge), 0) AS total_surcharge
FROM branches br
LEFT JOIN copies c ON c.branch_id = br.branch_id
LEFT JOIN loans l ON l.copy_id = c.copy_id
GROUP BY br.branch_id, br.name
ORDER BY total_surcharge DESC;| branch | total_surcharge |
|---|---|
| East | 4.20 |
| North | 1.40 |
| South | 1.40 |
| Central | 0.00 |
Total: €7.00, which matches the global SUM(surcharge) from section 1. Here there is no row explosion, because the three tables are chained in a line (branch → copy → loan), not hanging in parallel.
Solution 2
-- 1
SELECT a.last_name, COUNT(*) AS works
FROM books b
INNER JOIN authors a ON a.author_id = b.author_id
GROUP BY a.author_id, a.last_name
HAVING COUNT(*) > 1;| last_name | works |
|---|---|
| Barreda | 2 |
-- 2
SELECT b.title, COUNT(DISTINCT c.branch_id) AS branch_count
FROM books b
INNER JOIN copies c ON c.book_id = b.book_id
GROUP BY b.book_id, b.title
HAVING COUNT(DISTINCT c.branch_id) >= 3;| title | branch_count |
|---|---|
| The Map of Time | 3 |
The DISTINCT is essential: if a book had two copies at the same branch, COUNT(c.branch_id) would count them twice.
-- 3
SELECT m.first_name || ' ' || m.last_name AS member, COUNT(*) AS late_returns
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
WHERE l.return_date > l.due_date
GROUP BY m.member_id, m.first_name, m.last_name
ORDER BY late_returns DESC, member;| member | late_returns |
|---|---|
| Iván Pereda | 1 |
| Lucía Vendrell | 1 |
| Sonia Quiroga | 1 |
Note: the condition goes in WHERE because it filters rows (loans), not groups. That is the efficient way to filter.
-- 4
SELECT br.name AS branch, COUNT(*) AS available
FROM copies c
INNER JOIN branches br ON br.branch_id = c.branch_id
WHERE c.status = 'available'
GROUP BY br.branch_id, br.name
HAVING COUNT(*) >= 3
ORDER BY available DESC;| branch | available |
|---|---|
| Central | 4 |
Solution 3
-- 1 (PostgreSQL)
SELECT TO_CHAR(loan_date, 'YYYY-MM') AS month,
COUNT(*) AS loan_count,
COALESCE(SUM(surcharge), 0) AS surcharge
FROM loans
GROUP BY TO_CHAR(loan_date, 'YYYY-MM')
ORDER BY month;
-- SQLite: replace TO_CHAR(...) with strftime('%Y-%m', loan_date)| month | loan_count | surcharge |
|---|---|---|
| 2026-03 | 3 | 1.40 |
| 2026-04 | 2 | 1.40 |
| 2026-05 | 3 | 4.20 |
| 2026-07 | 4 | 0.00 |
The four July loans are open, so their surcharge is NULL and SUM returns NULL; the COALESCE turns it into 0.00. And notice that June does not appear: there were no loans that month and GROUP BY does not invent empty groups. To make it show up with a 0 you would have to generate the series of months and LEFT JOIN against it.
-- 2 Three independent branches: each one has to be aggregated separately
WITH loan_stats AS (
SELECT member_id, COUNT(*) AS loan_count, SUM(surcharge) AS surcharge
FROM loans GROUP BY member_id
),
reservation_stats AS (
SELECT member_id, COUNT(*) AS reservation_count
FROM reservations GROUP BY member_id
)
SELECT m.member_id,
m.first_name || ' ' || m.last_name AS member,
COALESCE(ls.loan_count, 0) AS loan_count,
COALESCE(rs.reservation_count, 0) AS reservation_count,
COALESCE(ls.surcharge, 0) AS surcharge
FROM members m
LEFT JOIN loan_stats ls ON ls.member_id = m.member_id
LEFT JOIN reservation_stats rs ON rs.member_id = m.member_id
ORDER BY m.member_id;| member_id | member | loan_count | reservation_count | surcharge |
|---|---|---|---|---|
| 11 | Álvaro Ferrán | 1 | 1 | 0.00 |
| 12 | Sonia Quiroga | 1 | 0 | 4.20 |
| 13 | Ramón Etxebarri | 0 | 0 | 0.00 |
| 14 | Marta Alsina | 3 | 1 | 0.00 |
| 15 | Iván Pereda | 2 | 1 | 1.40 |
| 16 | Nuria Bastos | 2 | 1 | 0.00 |
| 17 | Diego Salom | 1 | 0 | 0.00 |
| 18 | Lucía Vendrell | 1 | 1 | 1.40 |
| 19 | Pau Miralles | 1 | 0 | 0.00 |
| 20 | Elena Roig | 0 | 0 | 0.00 |
If you had solved it with two direct LEFT JOINs to loans and reservations, Marta Alsina would have come out with 3 loans and 3 reservations (3 × 1 = 3 rows), and Nuria Bastos with 2 and 2. The row explosion in its purest form.
-- 3
SELECT m.last_name,
l.loan_date,
COALESCE(l.surcharge, 0) AS surcharge,
SUM(COALESCE(l.surcharge, 0)) OVER (PARTITION BY l.member_id
ORDER BY l.loan_date) AS running_total
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
ORDER BY m.last_name, l.loan_date;| last_name | loan_date | surcharge | running_total |
|---|---|---|---|
| Alsina | 2026-03-02 | 0.00 | 0.00 |
| Alsina | 2026-04-06 | 0.00 | 0.00 |
| Alsina | 2026-07-14 | 0.00 | 0.00 |
| Bastos | 2026-03-11 | 0.00 | 0.00 |
| Bastos | 2026-05-04 | 0.00 | 0.00 |
| Ferrán | 2026-05-08 | 0.00 | 0.00 |
| Miralles | 2026-07-25 | 0.00 | 0.00 |
| Pereda | 2026-03-05 | 1.40 | 1.40 |
| Pereda | 2026-07-18 | 0.00 | 1.40 |
| Quiroga | 2026-05-19 | 4.20 | 4.20 |
| Salom | 2026-07-21 | 0.00 | 0.00 |
| Vendrell | 2026-04-12 | 1.40 | 1.40 |
By adding ORDER BY inside the OVER, SUM stops being a total and becomes a running total: each row includes all the previous ones in its partition. It is the mechanism behind any cumulative-evolution chart.
Conclusion
With this lesson BiblioRed can now answer not only "what is there", but "how much is there":
- The aggregate functions
COUNT,SUM,AVG,MINandMAXcondense many rows into one value. WithoutGROUP BY, the whole table is a single group. COUNThas three forms:COUNT(*)counts rows,COUNT(column)counts non-null values andCOUNT(DISTINCT column)counts distinct values.- Every aggregate ignores
NULLs exceptCOUNT(*), andSUMover pure nulls returnsNULL, not zero. Deciding what the absence means is a business decision, not a technical one. GROUP BYsplits the result into groups and returns one row per group; every non-aggregate column in theSELECTmust be in theGROUP BY(and SQLite does not check it, which is a serious trap).HAVINGfilters groups;WHEREfilters rows. Whatever can go inWHERE, goes inWHERE.- The logical order of execution —
FROM→JOIN→WHERE→GROUP BY→HAVING→SELECT→DISTINCT→ORDER BY→LIMIT— explains whyWHEREsees neither the aliases nor the aggregates andORDER BYdoes. - Aggregation combined with
JOINproduces the real reports: loans per branch, most borrowed books, activity per member. With one golden rule: withLEFT JOIN,COUNT(column), neverCOUNT(*). - The row explosion inflates counts when two independent tables hang off a third. It is spotted because the figures come out suspiciously high and identical, and it is solved by aggregating each branch separately with CTEs.
COALESCEturnsNULLinto something presentable andCASE WHEN(orFILTERin PostgreSQL) lets you cross-tabulate several conditional columns in a single pass.- Window functions with
OVER (PARTITION BY ...)compute aggregates without losing the detail, andROW_NUMBER,RANKandDENSE_RANKnumber and rank with different treatments of ties.
We now know how to define the schema, populate it, query it, cross it and summarize it. What remains is the question that holds up everything else: who guarantees that this data will still be true in five years' time? Our reports are reliable only because every member_id in loans points at a member who exists, and every book_id in copies, at a book in the catalog.
That is guaranteed by referential integrity, and it is the subject of lesson 02-06, which closes the module: how foreign keys are declared, what the manager checks on every INSERT, UPDATE and DELETE, what to do when you delete a row that others depend on (CASCADE, RESTRICT, SET NULL…), how to detect and clean up the orphan rows that already exist, and why SQLite protects nothing unless you expressly ask it to.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
