Warm-up · Activity 1 of 7
// B3.5 · ~30 min · Beginner
HAVING and FILTER
After this lesson you can keep only the groups that matter, count open and returned loans side by side, and list a group’s titles in one cell.
Predict · Activity 2 of 7
Predict before you read on. The sci-fi books have 2, 1 and 1 copies; classic, fiction and fantasy have one book each, with 1, 1 and 3 copies. What does this return?
SELECT genre, count(*) FROM books WHERE copies = 1 GROUP BY genre HAVING count(*) > 1;Practice · Activity 3 of 7
Fill in the key word so that the query returns only the genres with more than one book.
SELECT genre, count(*) FROM books GROUP BY genre ____ count(*) > 1;SELECT genre, count(*) FROM books GROUP BY genre count(*) > 1;Practice · Activity 4 of 7
Match each clause to what it filters or orders.
Practice · Activity 5 of 7
Five loans, three of them still open (returned_on is NULL). What does this return?
SELECT count(*) FILTER (WHERE returned_on IS NULL) AS open, count(*) AS all_loans FROM loans;Brain teaser · Activity 6 of 7
Brain teaser. Ada and Linus live in Berlin, Grace in Munich, and Margaret’s city is NULL. What does this return?
SELECT string_agg(city, ', ' ORDER BY city) FROM members;Apply · Activity 7 of 7
Mini-task. With the library of the worked example, write one query over loans joined to books: for each genre, the number of loans, the number still open, and the lent titles in alphabetical order in one cell. Keep only the genres with at least two loans. Predict first which genres survive and why one title appears twice.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Filtering groups and counting subsets
setup.sql loads the library of module B1: books, members and loans. main.sql keeps the genres with at least three copies, combines WHERE and HAVING, counts open and returned loans per member in one pass, and lists the titles of each genre. On your computer: psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sql.
main.sql
-- 1. Genres with at least three copies in all.
SELECT genre, count(*) AS books, sum(copies) AS copies
FROM books
GROUP BY genre
HAVING sum(copies) >= 3
ORDER BY genre;
-- 2. WHERE picks loans, HAVING picks members.
SELECT member_id, count(*) AS september_loans
FROM loans
WHERE loaned_on >= '2026-09-05'
GROUP BY member_id
HAVING count(*) >= 2
ORDER BY member_id;
-- 3. Several counts in one pass with FILTER.
SELECT m.name,
count(*) AS loans,
count(*) FILTER (WHERE l.returned_on IS NULL) AS open,
count(*) FILTER (WHERE l.returned_on IS NOT NULL) AS returned
FROM loans l
JOIN members m ON m.id = l.member_id
GROUP BY m.id, m.name
ORDER BY m.name;
-- 4. The titles of each genre in one cell.
SELECT genre, string_agg(title, ', ' ORDER BY title) AS titles
FROM books
GROUP BY genre
ORDER BY genre;
setup.sql
CREATE TABLE books (
id integer,
title text,
author text,
year integer,
genre text,
copies integer
);
INSERT INTO books VALUES
(1, 'Dune', 'Frank Herbert', 1965, 'sci-fi', 2),
(2, 'Emma', 'Jane Austen', 1815, 'classic', 1),
(3, 'Kindred', 'Octavia E. Butler', 1979, 'sci-fi', 1),
(4, 'Beloved', 'Toni Morrison', 1987, 'fiction', 1),
(5, 'The Hobbit', 'J. R. R. Tolkien', 1937, 'fantasy', 3),
(6, 'Neuromancer', 'William Gibson', 1984, 'sci-fi', 1);
CREATE TABLE members (
id integer,
name text,
email text,
city text,
joined date
);
INSERT INTO members VALUES
(1, 'Ada', 'ada@example.com', 'Berlin', '2025-01-15'),
(2, 'Grace', 'grace@example.com', 'Munich', '2025-03-02'),
(3, 'Linus', NULL, 'Berlin', '2026-02-10'),
(4, 'Margaret', 'margaret@example.com', NULL, '2026-05-20');
CREATE TABLE loans (
id integer,
book_id integer,
member_id integer,
loaned_on date,
returned_on date
);
INSERT INTO loans VALUES
(1, 1, 1, '2026-09-01', '2026-09-10'),
(2, 3, 1, '2026-09-12', NULL),
(3, 2, 2, '2026-09-05', '2026-09-20'),
(4, 1, 3, '2026-09-15', NULL),
(5, 6, 2, '2026-09-18', NULL);
Run it with
psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sqlOutput
genre | books | copies
---------+-------+--------
fantasy | 1 | 3
sci-fi | 3 | 4
(2 rows)
member_id | september_loans
-----------+-----------------
2 | 2
(1 row)
name | loans | open | returned
-------+-------+------+----------
Ada | 2 | 1 | 1
Grace | 2 | 1 | 1
Linus | 1 | 1 | 0
(3 rows)
genre | titles
---------+----------------------------
classic | Emma
fantasy | The Hobbit
fiction | Beloved
sci-fi | Dune, Kindred, Neuromancer
(4 rows)- HAVING sum(copies) >= 3 keeps fantasy (The Hobbit alone has 3) and sci-fi (2 + 1 + 1); the aggregate in HAVING is written out, not taken from the alias.
- WHERE first drops Ada’s loan of 2026-09-01; of the four loans left, only member 2 has two, so HAVING keeps one group.
- The three counts in the third query each see different rows: FILTER narrows the input of its own count only.
- ORDER BY inside string_agg sorts the titles within each cell; the ORDER BY at the end sorts the rows.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads PostgreSQL for your browser (up to 10.1 MB) and keeps it cached. Every run starts from an empty database. Your code stays on your device.
Exercises
Exercise 1 of 2
Only the groups that matter
Write two queries. First: the genres with at least two copies in all, with genre and the total copies (copies), sorted by genre. Second: the members who borrowed at least two books, with name and the number of loans (loans), sorted by name; join loans to members.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads PostgreSQL for your browser (up to 10.1 MB) and keeps it cached. Every run starts from an empty database. Your code stays on your device.
Hints
Hint 1
The condition is about a group’s total, so it goes in HAVING, between GROUP BY and ORDER BY.
Hint 2
Write the aggregate out in HAVING: HAVING sum(copies) >= 2. The alias copies is not known there.
Hint 3
For the members: JOIN members m ON m.id = l.member_id, GROUP BY m.id, m.name, HAVING count(*) >= 2.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
SELECT genre, sum(copies) AS copies
FROM books
GROUP BY genre
HAVING sum(copies) >= 2
ORDER BY genre;
SELECT m.name, count(*) AS loans
FROM loans l
JOIN members m ON m.id = l.member_id
GROUP BY m.id, m.name
HAVING count(*) >= 2
ORDER BY m.name;
Run it on your computer
Install PostgreSQL 18 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.sql
-- setup.sql has loaded books, members and loans.
SELECT genre, sum(copies) AS copies
FROM books
GROUP BY genre
ORDER BY genre;
test.sql
-- test: fantasy and sci-fi have at least two copies, then Ada and Grace have two loans each
-- output:
-- genre | copies
-- ---------+--------
-- fantasy | 3
-- sci-fi | 4
-- (2 rows)
--
-- name | loans
-- -------+-------
-- Ada | 2
-- Grace | 2
-- (2 rows)
setup.sql
CREATE TABLE books (
id integer,
title text,
author text,
year integer,
genre text,
copies integer
);
INSERT INTO books VALUES
(1, 'Dune', 'Frank Herbert', 1965, 'sci-fi', 2),
(2, 'Emma', 'Jane Austen', 1815, 'classic', 1),
(3, 'Kindred', 'Octavia E. Butler', 1979, 'sci-fi', 1),
(4, 'Beloved', 'Toni Morrison', 1987, 'fiction', 1),
(5, 'The Hobbit', 'J. R. R. Tolkien', 1937, 'fantasy', 3),
(6, 'Neuromancer', 'William Gibson', 1984, 'sci-fi', 1);
CREATE TABLE members (
id integer,
name text,
email text,
city text,
joined date
);
INSERT INTO members VALUES
(1, 'Ada', 'ada@example.com', 'Berlin', '2025-01-15'),
(2, 'Grace', 'grace@example.com', 'Munich', '2025-03-02'),
(3, 'Linus', NULL, 'Berlin', '2026-02-10'),
(4, 'Margaret', 'margaret@example.com', NULL, '2026-05-20');
CREATE TABLE loans (
id integer,
book_id integer,
member_id integer,
loaned_on date,
returned_on date
);
INSERT INTO loans VALUES
(1, 1, 1, '2026-09-01', '2026-09-10'),
(2, 3, 1, '2026-09-12', NULL),
(3, 2, 2, '2026-09-05', '2026-09-20'),
(4, 1, 3, '2026-09-15', NULL),
(5, 6, 2, '2026-09-18', NULL);
psql runs the files on your own PostgreSQL server. Use an empty database you can throw away: add -d and its name to the command.
Run the program:
psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sqlThere is no command for the checks on your computer yet. They are in test.sql: each check query prints t when it passes.
Exercise 2 of 2
Open, returned and which titles
The starter counts each member's loans. Replace the single count with two: open (no returned_on) and returned, using FILTER. Then add titles: the titles the member borrowed, in alphabetical order, separated by a comma and a space. Join books for the titles. Keep the sort by name.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads PostgreSQL for your browser (up to 10.1 MB) and keeps it cached. Every run starts from an empty database. Your code stays on your device.
Hints
Hint 1
count(*) FILTER (WHERE l.returned_on IS NULL) AS open counts only the open loans of each member.
Hint 2
The returned loans are the ones where l.returned_on IS NOT NULL.
Hint 3
JOIN books b ON b.id = l.book_id, then string_agg(b.title, ', ' ORDER BY b.title) AS titles; the ORDER BY goes after the separator.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
SELECT m.name,
count(*) FILTER (WHERE l.returned_on IS NULL) AS open,
count(*) FILTER (WHERE l.returned_on IS NOT NULL) AS returned,
string_agg(b.title, ', ' ORDER BY b.title) AS titles
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books b ON b.id = l.book_id
GROUP BY m.id, m.name
ORDER BY m.name;
Run it on your computer
Install PostgreSQL 18 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.sql
-- Loans per member: one count so far.
SELECT m.name, count(*) AS loans
FROM loans l
JOIN members m ON m.id = l.member_id
GROUP BY m.id, m.name
ORDER BY m.name;
test.sql
-- test: Each member with open and returned loans and the borrowed titles in alphabetical order
-- output:
-- name | open | returned | titles
-- -------+------+----------+-------------------
-- Ada | 1 | 1 | Dune, Kindred
-- Grace | 1 | 1 | Emma, Neuromancer
-- Linus | 1 | 0 | Dune
-- (3 rows)
setup.sql
CREATE TABLE books (
id integer,
title text,
author text,
year integer,
genre text,
copies integer
);
INSERT INTO books VALUES
(1, 'Dune', 'Frank Herbert', 1965, 'sci-fi', 2),
(2, 'Emma', 'Jane Austen', 1815, 'classic', 1),
(3, 'Kindred', 'Octavia E. Butler', 1979, 'sci-fi', 1),
(4, 'Beloved', 'Toni Morrison', 1987, 'fiction', 1),
(5, 'The Hobbit', 'J. R. R. Tolkien', 1937, 'fantasy', 3),
(6, 'Neuromancer', 'William Gibson', 1984, 'sci-fi', 1);
CREATE TABLE members (
id integer,
name text,
email text,
city text,
joined date
);
INSERT INTO members VALUES
(1, 'Ada', 'ada@example.com', 'Berlin', '2025-01-15'),
(2, 'Grace', 'grace@example.com', 'Munich', '2025-03-02'),
(3, 'Linus', NULL, 'Berlin', '2026-02-10'),
(4, 'Margaret', 'margaret@example.com', NULL, '2026-05-20');
CREATE TABLE loans (
id integer,
book_id integer,
member_id integer,
loaned_on date,
returned_on date
);
INSERT INTO loans VALUES
(1, 1, 1, '2026-09-01', '2026-09-10'),
(2, 3, 1, '2026-09-12', NULL),
(3, 2, 2, '2026-09-05', '2026-09-20'),
(4, 1, 3, '2026-09-15', NULL),
(5, 6, 2, '2026-09-18', NULL);
psql runs the files on your own PostgreSQL server. Use an empty database you can throw away: add -d and its name to the command.
Run the program:
psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sqlThere is no command for the checks on your computer yet. They are in test.sql: each check query prints t when it passes.
Common mistakes
An aggregate in WHERE
SELECT genre, sum(copies)
FROM books
WHERE sum(copies) >= 3
GROUP BY genre;
What psql prints
ERROR: aggregate functions are not allowed in WHEREWhy, and the fix
WHERE decides which rows go into the groups, so it runs before any sum exists. A condition on a group’s total belongs in HAVING, after GROUP BY: GROUP BY genre HAVING sum(copies) >= 3. Conditions on single rows, such as year > 1970, stay in WHERE.
An output alias in HAVING
SELECT genre, count(*) AS n
FROM books
GROUP BY genre
HAVING n > 1;
What psql prints
ERROR: column "n" does not existWhy, and the fix
The alias n is given by the select list, which is computed after HAVING, so HAVING does not know it. Repeat the aggregate: HAVING count(*) > 1. ORDER BY n would work, because ORDER BY runs after the select list. An alias that is also a table name is worse: HAVING books > 1 fails with operator does not exist: books > integer, because books then means the table’s whole row.
string_agg over numbers
SELECT genre, string_agg(year, ', ')
FROM books
GROUP BY genre;
What psql prints
ERROR: function string_agg(integer, unknown) does not existWhy, and the fix
string_agg joins text (or bytea), and year is an integer. Cast the values to text first: string_agg(year::text, ', ' ORDER BY year). Sorting by year itself keeps the numbers in numeric order, so 1965 comes before 1979 and 1984.
PostgreSQL in the browser: PGlite 0.5.8 (PostgreSQL 18.3), Apache-2.0 and PostgreSQL License. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.