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

  1. The aggregate functions
  2. COUNT and its three forms
  3. SUM, AVG, MIN and MAX
  4. How aggregates treat NULL values
  5. GROUP BY: aggregating by groups
  6. The rule about what can appear in the SELECT
  7. GROUP BY on several columns
  8. HAVING versus WHERE
  9. The logical order of execution of a query
  10. Aggregation combined with JOIN
  11. The problem of rows inflated by a JOIN
  12. COALESCE and CASE WHEN inside aggregates
  13. Introduction to window functions
  14. Common mistakes and tips
  15. Exercises
  16. Conclusion

  1. 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:

SELECT COUNT(*) FROM loans;
 count
-------
    12

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.

  1. COUNT and its three forms

COUNT 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 than COUNT(*). It is false in PostgreSQL and in any modern manager: they are identical. COUNT(DISTINCT column) is noticeably more expensive, because it forces deduplication.

  1. SUM, AVG, MIN and MAX

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

  1. How aggregates treat NULL values

Here there is one rule and one exception, and both are worth burning in:

Every aggregate function ignores NULLs. The only exception is COUNT(*), 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.

  1. GROUP BY: aggregating by groups

Until now we summarized the whole table. GROUP BY splits it into groups and computes the aggregates within each group, returning one row per group.

SELECT branch_id, COUNT(*) AS copy_count
FROM copies
GROUP BY branch_id
ORDER BY branch_id;
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:

SELECT status, COUNT(*) AS how_many
FROM copies
GROUP BY status
ORDER BY how_many DESC, status;
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

  1. The rule about what can appear in the SELECT

This is the rule that produces the most errors when starting out:

Every column that appears in the SELECT and is not inside an aggregate function must be listed in the GROUP 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:

SELECT branch_id, code, COUNT(*)
FROM copies
GROUP BY branch_id;
ERROR:  column "copies.code" must appear in the GROUP BY clause
        or be used in an aggregate function

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.

  1. GROUP BY on several columns

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

  1. HAVING versus WHERE

WHERE 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;
ERROR:  aggregate functions are not allowed in WHERE

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.

  1. 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:

  1. WHERE cannot use aggregates. It runs at step 3; the groups are formed at step 4.
  2. WHERE cannot use the SELECT aliases. The SELECT is step 6, which comes later. WHERE loan_count >= 2 raises a nonexistent-column error.
  3. ORDER BY can use the SELECT aliases. It is step 8, after step 6. That is why ORDER BY loan_count DESC worked.
  4. LIMIT comes last. It trims the final result, not the rows read: LIMIT 3 in a query with GROUP BY returns 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;
ERROR:  column "loan_count" does not exist

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.

  1. Aggregation combined with JOIN

This 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, never COUNT(*): always COUNT(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.

  1. The problem of rows inflated by a JOIN

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

  1. COALESCE and CASE WHEN inside aggregates

COALESCE: 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:

CASE WHEN condition1 THEN value1
     WHEN condition2 THEN value2
     ELSE default_value
END

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.

  1. 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:

SELECT member_id, COUNT(*) FROM loans GROUP BY member_id;   -- 8 rows

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_NUMBER never ties: it numbers 1, 2, 3, 4, 5, 6, 7 even when the values are equal.
  • RANK ties 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_RANK ties 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 complete GROUP BY.
  • Using COUNT(*) with a LEFT JOIN. It returns 1 where it should return 0, because it counts the NULL-filled row. Use COUNT(column_from_the_right_table).
  • Counting over a JOIN that inflates rows. If two independent tables hang off a third, each COUNT counts the Cartesian product. Aggregate each branch separately.
  • Putting a condition with an aggregate in WHERE. Guaranteed error: aggregates go in HAVING.
  • Putting a condition that uses no aggregates in HAVING. It works, but it filters later than necessary and confuses whoever reads it. It goes in WHERE.
  • Using a SELECT alias inside the WHERE. The SELECT is evaluated afterwards. In ORDER BY you can.
  • Assuming that SUM over pure NULLs gives zero. It gives NULL. Wrap it in COALESCE(SUM(x), 0) if the report needs a number.
  • Comparing AVG without deciding what to do with the NULLs. AVG(x) and AVG(COALESCE(x, 0)) give different figures and both can be correct: the question is what the absence means.
  • Believing that MIN(a) and MAX(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 BY starting with the primary key of the table you are summarizing (GROUP BY b.book_id, b.title, not just GROUP 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

  1. How many members are there at each branch? Show the branch name, including the branches that have none.
  2. How many books are there per language?
  3. How many copies does each book have? Include the title and sort from most to fewest.
  4. What is the total surcharge collected at each branch (by the branch of the copy lent)? Show 0.00 where there has been none.

Exercise 2: Grouping and filtering groups

  1. Which authors have more than one work in the collection?
  2. Which books have copies at three or more different branches?
  3. Which members have returned a book late, and how many times? Sort from most to fewest.
  4. Which branches have three or more available copies?

Exercise 3: Complete reports

  1. 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).
  2. 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.
  3. 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
-- 2
SELECT language, COUNT(*) AS book_count FROM books GROUP BY language ORDER BY book_count DESC;
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, MIN and MAX condense many rows into one value. Without GROUP BY, the whole table is a single group.
  • COUNT has three forms: COUNT(*) counts rows, COUNT(column) counts non-null values and COUNT(DISTINCT column) counts distinct values.
  • Every aggregate ignores NULLs except COUNT(*), and SUM over pure nulls returns NULL, not zero. Deciding what the absence means is a business decision, not a technical one.
  • GROUP BY splits the result into groups and returns one row per group; every non-aggregate column in the SELECT must be in the GROUP BY (and SQLite does not check it, which is a serious trap).
  • HAVING filters groups; WHERE filters rows. Whatever can go in WHERE, goes in WHERE.
  • The logical order of executionFROMJOINWHEREGROUP BYHAVINGSELECTDISTINCTORDER BYLIMIT— explains why WHERE sees neither the aliases nor the aggregates and ORDER BY does.
  • Aggregation combined with JOIN produces the real reports: loans per branch, most borrowed books, activity per member. With one golden rule: with LEFT JOIN, COUNT(column), never COUNT(*).
  • 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.
  • COALESCE turns NULL into something presentable and CASE WHEN (or FILTER in PostgreSQL) lets you cross-tabulate several conditional columns in a single pass.
  • Window functions with OVER (PARTITION BY ...) compute aggregates without losing the detail, and ROW_NUMBER, RANK and DENSE_RANK number 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.

© Copyright 2026. All rights reserved