Both terminals are open and the BiblioRed schema is at hand, exactly as we left it when closing module 6. From here on the course changes register: you are not going to read new concepts, you are going to write SQL. This lesson is a bank of fifteen exercises about the municipal library network of Vallmar, ordered by increasing difficulty, and the way to use it is very specific.
How to work through this lesson. Read the task. Before looking at anything else, write your query in the psql session and run it. Compare your result with the Expected result block. Only then unfold the Solution and compare it with yours: it is perfectly normal —and frequently desirable— for your query to be different from the proposed one and give the same result, because in SQL there is almost always more than one road. What is not normal is reading the solution first: in that case the exercise teaches you nothing, it only gives you the feeling of having understood.
If a query will not come out, hold on for at least five minutes before looking at the Hint. The muscle you are training here is not the one that remembers the syntax of HAVING, but the one that translates a question in human language into a query, and that is trained only by failing.
This lesson covers SELECT, WHERE, JOIN, aggregation and subqueries. Window functions, transactions and execution plans do not appear here: they are the material of lesson 07-04.
Before You Begin
The exercises use a small, specific dataset —8 members, 12 materials, 15 copies, 20 loans— so that you can verify every result by eye. If you have been building BiblioRed up along the course, your data will be different and the results will not match. That is why it is worth starting from scratch with this lesson's script.
Creation and load script
Create a clean database and run this whole script. In PostgreSQL:
-- ============================================================
-- BiblioRed - reduced dataset for module 7
-- PostgreSQL 14+. All the data is fictional.
-- ============================================================
DROP TABLE IF EXISTS payments, fines, registrations, participations, speakers,
events, event_types, rooms, reservations, loans,
copies, materials, member_phones, members,
authors, branches CASCADE;
CREATE TABLE branches (
branch_id INTEGER PRIMARY KEY,
name VARCHAR(60) NOT NULL UNIQUE,
addr_street VARCHAR(80),
addr_number VARCHAR(10),
addr_postal_code CHAR(5),
addr_city VARCHAR(60) NOT NULL DEFAULT 'Vallmar',
phone VARCHAR(15),
opening_date DATE
);
CREATE TABLE authors (
author_id INTEGER PRIMARY KEY,
first_name VARCHAR(60) NOT NULL,
last_name VARCHAR(80) NOT NULL,
nationality VARCHAR(40),
birth_year SMALLINT
);
CREATE TABLE members (
member_id INTEGER PRIMARY KEY,
first_name VARCHAR(60) NOT NULL,
last_name VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
join_date DATE NOT NULL,
branch_id INTEGER NOT NULL REFERENCES branches(branch_id),
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE member_phones (
member_id INTEGER NOT NULL REFERENCES members(member_id) ON DELETE CASCADE,
number VARCHAR(15) NOT NULL,
type VARCHAR(10) NOT NULL,
PRIMARY KEY (member_id, number)
);
CREATE TABLE materials (
material_id INTEGER PRIMARY KEY,
material_type VARCHAR(15) NOT NULL,
title VARCHAR(200) NOT NULL,
author_id INTEGER REFERENCES authors(author_id),
publisher VARCHAR(80),
publication_year SMALLINT,
language CHAR(2) NOT NULL DEFAULT 'es',
added_date DATE NOT NULL,
cover_url VARCHAR(200)
);
CREATE TABLE copies (
copy_id INTEGER PRIMARY KEY,
code VARCHAR(15) NOT NULL UNIQUE,
material_id INTEGER NOT NULL REFERENCES materials(material_id),
copy_number SMALLINT NOT NULL,
branch_id INTEGER NOT NULL REFERENCES branches(branch_id),
status VARCHAR(15) NOT NULL,
acquisition_date DATE
);
CREATE TABLE loans (
loan_id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(member_id),
copy_id INTEGER NOT NULL REFERENCES copies(copy_id),
loan_date DATE NOT NULL,
due_date DATE NOT NULL,
return_date DATE
);
CREATE TABLE reservations (
reservation_id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(member_id),
material_id INTEGER NOT NULL REFERENCES materials(material_id),
reservation_date DATE NOT NULL,
expiry_date DATE,
status VARCHAR(15) NOT NULL
);
CREATE TABLE rooms (
room_id INTEGER PRIMARY KEY,
branch_id INTEGER NOT NULL REFERENCES branches(branch_id),
name VARCHAR(60) NOT NULL,
capacity SMALLINT NOT NULL,
floor SMALLINT NOT NULL,
accessible BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (branch_id, name)
);
CREATE TABLE event_types (
event_type_id INTEGER PRIMARY KEY,
code VARCHAR(10) NOT NULL UNIQUE,
name VARCHAR(60) NOT NULL,
description TEXT,
standard_duration_min SMALLINT,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE events (
event_id INTEGER PRIMARY KEY,
title VARCHAR(150) NOT NULL,
description TEXT,
event_type_id INTEGER NOT NULL REFERENCES event_types(event_type_id),
room_id INTEGER NOT NULL REFERENCES rooms(room_id),
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
offered_seats SMALLINT NOT NULL,
status VARCHAR(15) NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
version INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE registrations (
event_id INTEGER NOT NULL REFERENCES events(event_id),
member_id INTEGER NOT NULL REFERENCES members(member_id),
registration_date DATE NOT NULL,
status VARCHAR(15) NOT NULL,
companions SMALLINT NOT NULL DEFAULT 0,
occupied_seats SMALLINT NOT NULL DEFAULT 1,
PRIMARY KEY (event_id, member_id)
);
CREATE TABLE speakers (
speaker_id INTEGER PRIMARY KEY,
first_name VARCHAR(60) NOT NULL,
last_name VARCHAR(80) NOT NULL,
email VARCHAR(120) NOT NULL UNIQUE,
biography TEXT,
external BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE participations (
event_id INTEGER NOT NULL REFERENCES events(event_id),
speaker_id INTEGER NOT NULL REFERENCES speakers(speaker_id),
role VARCHAR(30) NOT NULL,
fee NUMERIC(8,2) NOT NULL DEFAULT 0,
PRIMARY KEY (event_id, speaker_id, role)
);
CREATE TABLE fines (
fine_id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(member_id),
loan_id INTEGER REFERENCES loans(loan_id),
reason VARCHAR(15) NOT NULL,
amount NUMERIC(8,2) NOT NULL,
issue_date DATE NOT NULL,
status VARCHAR(15) NOT NULL
);
CREATE TABLE payments (
payment_id INTEGER PRIMARY KEY,
fine_id INTEGER NOT NULL REFERENCES fines(fine_id),
payment_date DATE NOT NULL,
amount NUMERIC(8,2) NOT NULL,
method VARCHAR(15) NOT NULL,
reference VARCHAR(30)
);
-- ---------------- DATA ----------------
INSERT INTO branches VALUES
(1,'Central','Plaza Mayor','1','08130','Vallmar','935550001','1988-05-12'),
(2,'North','Avenida del Bosque','44','08131','Vallmar','935550002','2001-09-03'),
(3,'South','Calle Marina','7','08132','Vallmar','935550003','2010-01-18'),
(4,'East','Ronda de Levante','120','08133','Vallmar','935550004','2019-11-25');
INSERT INTO authors VALUES
(1,'Ken','Follett','British',1949),
(2,'Félix J.','Palma','Spanish',1968),
(3,'Almudena','Grandes','Spanish',1960),
(4,'Haruki','Murakami','Japanese',1949),
(5,'Svetlana','Alexievich','Belarusian',1948),
(6,'Delia','Marchetti','Argentine',NULL);
INSERT INTO members VALUES
(11,'Clara','Ferrán','clara.ferran@example.org','2024-03-12',1,TRUE),
(12,'Dídac','Rovira','didac.rovira@example.org','2024-06-01',2,TRUE),
(13,'Sonia','Mestre','sonia.mestre@example.org','2025-01-20',1,TRUE),
(14,'Marta','Alsina','marta.alsina@example.org','2025-02-14',1,TRUE),
(15,'Iván','Pereda','ivan.pereda@example.org','2025-04-03',2,TRUE),
(16,'Nuria','Bastos','nuria.bastos@example.org','2025-09-30',3,TRUE),
(17,'Óscar','Vilanova','oscar.vilanova@example.org','2026-01-15',4,FALSE),
(18,'Berta','Quintana','berta.quintana@example.org','2026-02-08',3,TRUE);
INSERT INTO member_phones VALUES
(11,'931222333','landline'),(14,'600111222','mobile'),
(14,'931000111','landline'),(15,'600333444','mobile'),(16,'600555666','mobile');
INSERT INTO materials VALUES
(901,'book','The Pillars of the Earth',1,'Andana Press',1989,'es','2024-01-10','/img/901.jpg'),
(902,'book','The Map of Time',2,'Marlia Editions',2008,'es','2024-01-10','/img/902.jpg'),
(903,'book','World Without End',1,'Andana Press',2007,'es','2024-02-05',NULL),
(904,'book','The Frozen Heart',3,'Riverbend Books',2007,'es','2024-03-01','/img/904.jpg'),
(905,'book','Norwegian Wood',4,'Riverbend Books',1987,'es','2024-03-01',NULL),
(906,'book','Kafka on the Shore',4,'Riverbend Books',2002,'es','2025-01-12','/img/906.jpg'),
(907,'book','The Wishing Box',6,'Vallmar Press',2019,'es','2025-02-20',NULL),
(908,'dvd','The Pillars of the Earth (series)',1,'Sono Media',2010,'es','2025-03-15','/img/908.jpg'),
(909,'dvd','Documentary: The Voice of Chernobyl',5,'Delta Films',2019,'es','2025-04-02',NULL),
(910,'magazine','Vallmar Science 42',NULL,'Vallmar City Council',2026,'es','2026-01-08',NULL),
(911,'magazine','Vallmar Science 43',NULL,'Vallmar City Council',2026,'es','2026-02-08',NULL),
(912,'audiobook','Voices from Chernobyl',5,'Riverbend Audio',2015,'es','2025-06-11','/img/912.jpg');
INSERT INTO copies VALUES
(3081,'EJ-3081',902,1,1,'on_loan','2024-01-15'),
(3082,'EJ-3082',902,2,2,'available','2024-01-15'),
(3083,'EJ-3083',901,1,1,'available','2024-01-15'),
(3084,'EJ-3084',901,2,3,'available','2024-01-15'),
(3085,'EJ-3085',901,3,4,'reserved','2025-02-01'),
(3086,'EJ-3086',903,1,1,'available','2024-02-10'),
(3087,'EJ-3087',904,1,2,'on_loan','2024-03-05'),
(3088,'EJ-3088',905,1,1,'on_loan','2024-03-05'),
(3089,'EJ-3089',906,1,3,'available','2025-01-20'),
(3090,'EJ-3090',906,2,1,'available','2025-01-20'),
(3091,'EJ-3091',908,1,1,'on_loan','2025-03-20'),
(3092,'EJ-3092',909,1,2,'available','2025-04-10'),
(3093,'EJ-3093',912,1,1,'lost','2025-06-15'),
(3094,'EJ-3094',910,1,1,'available','2026-01-10'),
(3095,'EJ-3095',904,2,4,'withdrawn','2024-03-05');
INSERT INTO loans VALUES
(1,14,3081,'2026-01-10','2026-01-31','2026-01-28'),
(2,14,3083,'2026-02-02','2026-02-23','2026-03-05'),
(3,15,3082,'2026-02-10','2026-03-03','2026-02-28'),
(4,16,3084,'2026-02-15','2026-03-08','2026-03-08'),
(5,11,3086,'2026-03-01','2026-03-22','2026-03-20'),
(6,12,3087,'2026-03-05','2026-03-26','2026-04-10'),
(7,14,3089,'2026-03-12','2026-04-02','2026-03-30'),
(8,15,3090,'2026-04-01','2026-04-22','2026-04-20'),
(9,13,3088,'2026-04-05','2026-04-26',NULL),
(10,16,3091,'2026-04-20','2026-05-11','2026-05-09'),
(11,11,3092,'2026-05-02','2026-05-23','2026-05-21'),
(12,14,3093,'2026-05-10','2026-05-31',NULL),
(13,12,3081,'2026-05-15','2026-06-05','2026-06-03'),
(14,15,3083,'2026-06-01','2026-06-22','2026-06-30'),
(15,16,3087,'2026-06-10','2026-07-01',NULL),
(16,13,3089,'2026-06-15','2026-07-06','2026-07-04'),
(17,14,3091,'2026-07-20','2026-08-10',NULL),
(18,11,3082,'2026-07-05','2026-07-26','2026-07-24'),
(19,15,3081,'2026-07-22','2026-08-12',NULL),
(20,12,3086,'2026-07-10','2026-07-31','2026-07-29');
INSERT INTO reservations VALUES
(501,14,901,'2026-01-05','2026-01-19','fulfilled'),
(502,15,902,'2026-07-18','2026-08-01','active'),
(503,16,906,'2026-06-01','2026-06-15','expired'),
(504,11,908,'2026-07-25','2026-08-08','active'),
(505,13,902,'2026-05-02','2026-05-16','cancelled'),
(506,12,901,'2026-03-30','2026-04-13','expired');
INSERT INTO rooms VALUES
(1,1,'Auditorium',120,0,TRUE),(2,1,'Blue Room',30,1,TRUE),
(3,2,'North Room',40,0,TRUE),(4,3,'South Classroom',25,1,FALSE),
(5,4,'East Room',50,0,TRUE);
INSERT INTO event_types VALUES
(1,'CLUB','Book club',NULL,90,TRUE),
(2,'STORY','Storytelling for children',NULL,45,TRUE),
(3,'WORKSHOP','Workshop',NULL,120,TRUE),
(4,'LAUNCH','Book launch',NULL,60,TRUE);
INSERT INTO events VALUES
(101,'Book club: The Pillars of the Earth',NULL,1,2,'2026-03-12 18:00','2026-03-12 19:30',12,'held',TRUE,3),
(102,'Spring storytelling',NULL,2,3,'2026-04-18 11:00','2026-04-18 11:45',20,'held',TRUE,2),
(103,'Creative writing workshop',NULL,3,4,'2026-05-09 17:00','2026-05-09 19:00',8,'held',TRUE,4),
(104,'Book launch: The Wishing Box',NULL,4,1,'2026-06-04 19:00','2026-06-04 20:00',40,'held',TRUE,2),
(105,'Book club: Norwegian Wood',NULL,1,5,'2026-07-16 18:00','2026-07-16 19:30',10,'held',TRUE,3),
(106,'Introduction to genealogy workshop',NULL,3,2,'2026-09-10 17:00','2026-09-10 19:00',15,'open',TRUE,1),
(107,'Autumn storytelling',NULL,2,3,'2026-10-03 11:00','2026-10-03 11:45',20,'scheduled',FALSE,1);
INSERT INTO registrations VALUES
(101,14,'2026-02-20','attended',1,2),(101,11,'2026-02-22','attended',0,1),
(101,15,'2026-03-01','cancelled',0,1),(101,12,'2026-03-02','attended',0,1),
(101,13,'2026-03-03','attended',2,3),
(102,16,'2026-04-01','attended',3,4),(102,12,'2026-04-02','attended',2,3),
(102,11,'2026-04-05','confirmed',1,2),(102,15,'2026-04-06','cancelled',0,1),
(103,14,'2026-04-20','attended',0,1),(103,13,'2026-04-21','attended',0,1),
(103,15,'2026-04-22','attended',1,2),(103,16,'2026-04-25','waiting_list',0,1),
(103,11,'2026-04-26','confirmed',0,1),
(104,11,'2026-05-10','attended',1,2),(104,12,'2026-05-11','attended',0,1),
(104,14,'2026-05-12','attended',3,4),(104,16,'2026-05-14','attended',0,1),
(104,13,'2026-05-15','cancelled',0,1),
(105,15,'2026-06-30','attended',0,1),(105,16,'2026-07-01','attended',1,2),
(105,14,'2026-07-02','attended',0,1),(105,11,'2026-07-03','waiting_list',0,1),
(106,14,'2026-07-28','confirmed',1,2),(106,15,'2026-07-29','confirmed',0,1),
(106,16,'2026-07-30','confirmed',0,1);
INSERT INTO speakers VALUES
(1,'Rosa','Calduch','rosa.calduch@example.org',NULL,FALSE),
(2,'Aitor','Lemus','aitor.lemus@example.org',NULL,TRUE),
(3,'Delia','Marchetti','delia.marchetti@example.org',NULL,TRUE);
INSERT INTO participations VALUES
(101,1,'moderator',0),(102,2,'storyteller',180.00),(103,2,'workshop_leader',350.00),
(104,3,'author',250.00),(104,1,'host',0),(105,1,'moderator',0),
(106,2,'workshop_leader',300.00);
INSERT INTO fines VALUES
(1,14,2,'late_return',2.20,'2026-03-05','paid'),
(2,12,6,'late_return',3.00,'2026-04-10','paid'),
(3,15,14,'late_return',1.60,'2026-06-30','pending'),
(4,14,12,'loss',24.00,'2026-06-01','pending'),
(5,16,10,'damage',6.50,'2026-05-09','paid'),
(6,11,5,'damage',4.00,'2026-03-20','waived'),
(7,13,9,'late_return',5.00,'2026-07-20','pending'),
(8,16,15,'late_return',3.20,'2026-07-25','voided');
INSERT INTO payments VALUES
(1,1,'2026-03-06',2.20,'cash','TILL-C-0341'),
(2,2,'2026-04-12',3.00,'card','POS-000912'),
(3,5,'2026-05-10',4.00,'card','POS-001033'),
(4,5,'2026-05-18',2.50,'gateway','GWY-77120');Checking that the data is loaded
Run this query before you start. If any number does not match, the script has not run all the way through:
SELECT 'branches' AS table_, count(*) FROM branches
UNION ALL SELECT 'authors', count(*) FROM authors
UNION ALL SELECT 'members', count(*) FROM members
UNION ALL SELECT 'materials', count(*) FROM materials
UNION ALL SELECT 'copies', count(*) FROM copies
UNION ALL SELECT 'loans', count(*) FROM loans
UNION ALL SELECT 'reservations', count(*) FROM reservations
UNION ALL SELECT 'events', count(*) FROM events
UNION ALL SELECT 'registrations', count(*) FROM registrations
UNION ALL SELECT 'fines', count(*) FROM fines
UNION ALL SELECT 'payments', count(*) FROM payments
ORDER BY 1;| table_ | count |
|---|---|
| authors | 6 |
| branches | 4 |
| copies | 15 |
| events | 7 |
| fines | 8 |
| loans | 20 |
| materials | 12 |
| members | 8 |
| payments | 4 |
| registrations | 26 |
| reservations | 6 |
The reference date. Several exercises compute delays. So that the results are reproducible today and two years from now, the solutions use the literal date DATE '2026-08-02' instead of CURRENT_DATE. In production you would write CURRENT_DATE; here we need the number of days not to change.
SQLite. The dataset works in SQLite by changing SERIAL/BOOLEAN for integers and NUMERIC(8,2) for REAL. Wherever there are relevant differences in a query, the solution points them out.
Contents
- Block A — Basic: single-table queries (exercises 1-3)
- Block B — Intermediate: multi-table queries (exercises 4-7)
- Block C — Intermediate: aggregation and grouping (exercises 8-10)
- Block D — Advanced: subqueries, derived tables and CTEs (exercises 11-12)
- Block E — Advanced: real management reports (exercises 13-15)
- Common mistakes and tips
- Reinforcement exercises
Block A — Basic: a single table
Exercise 1: Projection, aliases and ordering
Difficulty: Basic
Task. The heritage department wants the listing of the five oldest materials in the catalog. Return three columns with these exact headers: Title, Publisher and Year. Order from oldest to most recent and, when two materials are from the same year, alphabetically by title.
Solution
SELECT title AS "Title",
publisher AS "Publisher",
publication_year AS "Year"
FROM materials
ORDER BY publication_year ASC, title ASC
LIMIT 5;Expected result
| Title | Publisher | Year |
|---|---|---|
| Norwegian Wood | Riverbend Books | 1987 |
| The Pillars of the Earth | Andana Press | 1989 |
| Kafka on the Shore | Riverbend Books | 2002 |
| The Frozen Heart | Riverbend Books | 2007 |
| World Without End | Andana Press | 2007 |
Explanation. There are three details that separate a correct query from a careless one:
- The double quotes around the aliases.
AS Titlewould come out astitlein lowercase, because PostgreSQL folds every unquoted identifier to lowercase. To keep the capital letter you need double quotes, not single ones: single quotes delimit text strings, not identifiers.AS 'Title'is a syntax error. - The second ordering criterion. Without
, title ASC, the two 2007 rows would come out in an order the engine does not guarantee. It may look stable to you today and change tomorrow when a row is added or an index is created. AnORDER BYthat does not break ties is not deterministic, and in a listing withLIMITthat means the row you see may vary between runs. LIMITgoes afterORDER BY, not before: first the whole set is ordered, then five rows are cut off. If you did it the other way round you would get any five rows and then order them, which is a different question.
In SQLite the query works the same, but quoted aliases behave more loosely (it accepts double quotes and also square brackets).
Exercise 2: Filters with BETWEEN, IN, LIKE, IS NULL and DISTINCT
Difficulty: Basic
Task. Answer the following five questions, each one with its own query:
- (a) Materials published between 2000 and 2010, both included, with title and year.
- (b) Copies whose status is
on_loanorreserved, with code, status and branch. - (c) Materials whose title contains the word "Chernobyl", regardless of upper or lower case.
- (d) Materials with no cover (
cover_urlnot filled in) and authors with no birth year. - (e) The distinct material types there are in the catalog and how many distinct publishers appear.
Hint. For (c), PostgreSQL has ILIKE; for (d), remember that = NULL is never true.
Solution
-- (a) BETWEEN is inclusive at both ends
SELECT title, publication_year
FROM materials
WHERE publication_year BETWEEN 2000 AND 2010
ORDER BY publication_year, title;
-- (b) IN replaces a chain of ORs
SELECT c.code, c.status, b.name AS branch
FROM copies c
JOIN branches b ON b.branch_id = c.branch_id
WHERE c.status IN ('on_loan', 'reserved')
ORDER BY c.code;
-- (c) ILIKE = case-insensitive LIKE (PostgreSQL)
SELECT material_id, title
FROM materials
WHERE title ILIKE '%chernobyl%'
ORDER BY material_id;
-- (d) IS NULL, never = NULL
SELECT material_id, title FROM materials WHERE cover_url IS NULL ORDER BY material_id;
SELECT author_id, first_name, last_name FROM authors WHERE birth_year IS NULL;
-- (e) DISTINCT and COUNT(DISTINCT ...)
SELECT DISTINCT material_type FROM materials ORDER BY material_type;
SELECT count(DISTINCT publisher) AS publishers FROM materials;Expected result
(a) 5 rows: Kafka on the Shore (2002), The Frozen Heart (2007), World Without End (2007), The Map of Time (2008), The Pillars of the Earth (series) (2010).
(b)
| code | status | branch |
|---|---|---|
| EJ-3081 | on_loan | Central |
| EJ-3085 | reserved | East |
| EJ-3087 | on_loan | North |
| EJ-3088 | on_loan | Central |
| EJ-3091 | on_loan | Central |
(c) 2 rows: 909 "Documentary: The Voice of Chernobyl" and 912 "Voices from Chernobyl".
(d) 6 materials with no cover (903, 905, 907, 909, 910, 911) and 1 author with no birth year (Delia Marchetti).
(e) 4 types (audiobook, book, dvd, magazine) and 8 distinct publishers.
Explanation. Each part hides a classic trap:
BETWEEN 2000 AND 2010includes both ends. If you need to exclude them,BETWEENis no use: you have to write> 2000 AND < 2010. And the order matters:BETWEEN 2010 AND 2000returns zero rows, with no error and no warning.IN ('on_loan','reserved')is exactly equivalent tostatus = 'on_loan' OR status = 'reserved'. It is more readable and, above all, it avoids the precedence mistake of writingWHERE material_id = 901 AND status = 'on_loan' OR status = 'reserved', which does not mean what it looks like:ANDbinds more tightly thanOR.ILIKEis a PostgreSQL extension. In SQLite,LIKEis already case-insensitive for ASCII characters, but not for accented ones —a surname likeFerránwould not matchferran—; there the portable form isWHERE lower(title) LIKE lower('%chernobyl%').cover_url = NULLreturnsNULL, which in aWHEREbehaves as false: the query does not raise an error, it gives zero rows. It is one of the hardest failures to spot because there is no symptom at all.count(DISTINCT publisher)counts 8 and not 9: there are 12 materials but only 8 distinct publishers, andcount(DISTINCT ...)ignores NULLs (there are no null publishers here, but it is worth remembering).
Exercise 3: INSERT, UPDATE and DELETE with the discipline of the prior SELECT
Difficulty: Basic
Task. Three maintenance operations. In all three, before modifying anything, write and run the SELECT with the same WHERE to see exactly which rows you are going to touch.
- (a) Register member 19, Rubén Ortells, email
ruben.ortells@example.org, joining today (2026-08-02), East branch, active. - (b) Member 18 (Berta Quintana) moves from the South branch to the North branch. Update her branch.
- (c) Delete the expired reservations whose expiry date is earlier than June 30, 2026.
Solution
-- (a) INSERT with an explicit column list
INSERT INTO members (member_id, first_name, last_name, email, join_date, branch_id, active)
VALUES (19, 'Rubén', 'Ortells', 'ruben.ortells@example.org', DATE '2026-08-02', 4, TRUE);
-- (b) Look first...
SELECT member_id, first_name, last_name, branch_id FROM members WHERE member_id = 18;
-- ...and only then modify
UPDATE members
SET branch_id = 2
WHERE member_id = 18
RETURNING member_id, last_name, branch_id;
-- (c) Look first...
SELECT reservation_id, member_id, material_id, expiry_date, status
FROM reservations
WHERE status = 'expired' AND expiry_date < DATE '2026-06-30';
-- ...and only then delete
DELETE FROM reservations
WHERE status = 'expired' AND expiry_date < DATE '2026-06-30'
RETURNING reservation_id;Expected result
INSERT 0 1
member_id | last_name | branch_id
-----------+-----------+-----------
18 | Quintana | 2
UPDATE 1
reservation_id
----------------
503
506
DELETE 2Explanation. Three habits that prevent almost every front-desk disaster:
- An explicit column list in the
INSERT.INSERT INTO members VALUES (...)works until the day somebody adds a column tomembers; then everyINSERTwithout a list breaks or, worse, puts the values in the wrong column. - The
SELECTwith the sameWHERE, first. This is not a style recommendation: it is the only way of knowing how many rows you are going to touch before touching them. AnUPDATE members SET branch_id = 2with noWHEREaffects all eight members, and there is no "undo" outside a transaction. RETURNING(PostgreSQL) returns the rows actually affected. It is the after-the-fact confirmation that theWHEREdid what was intended. SQLite has supported it since version 3.35; in MySQL it does not exist and you have to query again.
About (c): if you tried to delete reservation 501 (fulfilled), nothing would fail because no table points at reservations. But if you tried to delete member 14 it would fail, because loans, reservations, fines and registrations reference it. That is the referential integrity of module 2 doing its job.
Undo the changes before moving on, so that the later results match:
DELETE FROM members WHERE member_id = 19; UPDATE members SET branch_id = 3 WHERE member_id = 18;INSERT INTO reservations VALUES (503,16,906,'2026-06-01','2026-06-15','expired'), (506,12,901,'2026-03-30','2026-04-13','expired');
Block B — Intermediate: several tables
Exercise 4: INNER JOIN of two tables
Difficulty: Intermediate
Task. A member asks at the front desk where they can find "The Pillars of the Earth" (material 901). Return all its copies with the code, the status and the name of the branch where each one is, ordered by branch.
Hint. The branch is in copies.branch_id, not in materials.
Solution
SELECT c.code,
c.copy_number,
c.status,
b.name AS branch
FROM copies c
INNER JOIN branches b ON b.branch_id = c.branch_id
WHERE c.material_id = 901
ORDER BY b.name;Expected result
| code | copy_number | status | branch |
|---|---|---|---|
| EJ-3083 | 1 | available | Central |
| EJ-3085 | 3 | reserved | East |
| EJ-3084 | 2 | available | South |
Explanation. The JOIN connects two tables through the foreign key → primary key pair: copies.branch_id points at branches.branch_id. The aliases c and b are not decorative: as soon as there are two tables with a column of the same name —and here branch_id is in both— writing plain branch_id produces the error column reference "branch_id" is ambiguous.
The easy mistake here is forgetting the ON condition. A FROM copies c, branches b WHERE c.material_id = 901 with no join condition produces a Cartesian product: 3 copies × 4 branches = 12 rows, all of them plausible-looking. It is a mistake that does not jump out at you with three rows and that, with 40,000 copies, brings the server down.
Exercise 5: The chain of four tables
Difficulty: Intermediate
Task. List all the open loans (the ones not yet returned) with: the member's first and last name, the copy code, the material title, the branch the copy belongs to and the due date. Order by due date.
Hint. The chain is members → loans → copies → materials, and the branch hangs off copies.
Solution
SELECT me.first_name || ' ' || me.last_name AS member,
c.code,
m.title,
b.name AS branch,
l.due_date AS due
FROM loans l
INNER JOIN members me ON me.member_id = l.member_id
INNER JOIN copies c ON c.copy_id = l.copy_id
INNER JOIN materials m ON m.material_id = c.material_id
INNER JOIN branches b ON b.branch_id = c.branch_id
WHERE l.return_date IS NULL
ORDER BY l.due_date;Expected result
| member | code | title | branch | due |
|---|---|---|---|---|
| Sonia Mestre | EJ-3088 | Norwegian Wood | Central | 2026-04-26 |
| Marta Alsina | EJ-3093 | Voices from Chernobyl | Central | 2026-05-31 |
| Nuria Bastos | EJ-3087 | The Frozen Heart | North | 2026-07-01 |
| Marta Alsina | EJ-3091 | The Pillars of the Earth (series) | Central | 2026-08-10 |
| Iván Pereda | EJ-3081 | The Map of Time | Central | 2026-08-12 |
Explanation. Four chained JOINs are no harder than one: each JOIN adds a table and its condition, and the accumulated result keeps growing to the right. The key is to start from the table that contains the rows you want to count —here loans, because one result row is one loan— and hang the others off it as decoration.
Two observations:
l.return_date IS NULLis the definition of "open loan" in this schema. There is noopencolumn; the fact of having no return date is the fact of being open. It is a legitimate use ofNULL: it means "it has not happened yet".- The branch comes from
copies, not frommembers. Marta Alsina is registered at Central and her two loans are of Central copies, so the difference does not show; but if she had borrowed a North copy,b.namewould say North. Confusing "the member's branch" with "the copy's branch" is the most frequent modeling mistake in this schema. In SQLite,||concatenates just as in PostgreSQL.
Exercise 6: LEFT JOIN and the anti-join with IS NULL
Difficulty: Intermediate
Task. Two questions from the annual report:
- (a) Which materials have never been loaned out? Include also the ones that do not even have copies.
- (b) Which members have no loan on record? Say whether they are active.
Hint. A LEFT JOIN followed by WHERE <column of the right-hand table> IS NULL leaves exactly the rows that did not find a match.
Solution
-- (a) Materials never loaned out (two chained LEFT JOINs)
SELECT m.material_id, m.title, m.material_type
FROM materials m
LEFT JOIN copies c ON c.material_id = m.material_id
LEFT JOIN loans l ON l.copy_id = c.copy_id
WHERE l.loan_id IS NULL
ORDER BY m.material_id;
-- (b) Members with no loans
SELECT me.member_id, me.first_name, me.last_name, me.active
FROM members me
LEFT JOIN loans l ON l.member_id = me.member_id
WHERE l.loan_id IS NULL
ORDER BY me.member_id;Expected result
(a)
| material_id | title | material_type |
|---|---|---|
| 907 | The Wishing Box | book |
| 910 | Vallmar Science 42 | magazine |
| 911 | Vallmar Science 43 | magazine |
(b)
| member_id | first_name | last_name | active |
|---|---|---|---|
| 17 | Óscar | Vilanova | f |
| 18 | Berta | Quintana | t |
Explanation. The anti-join pattern has three parts that must be respected together:
LEFT JOIN, notINNER JOIN. TheINNERdiscards precisely the rows we are looking for.- The matching condition goes in the
ON. - The
IS NULLfilter goes in theWHEREand must point at a column of the right-hand table that is never null by itself: that is why we usel.loan_id(primary key, never null) and notl.return_date, which is null in open loans and would slip five false positives past us.
In (a) the second LEFT JOIN is essential. Materials 907 and 911 have no copies: after the first LEFT JOIN, c.copy_id is already NULL, and the second LEFT JOIN propagates that NULL to l.loan_id, so they make it into the result. If you had used INNER JOIN copies, those two materials would have vanished and the report would have said there is only one material never loaned out. The anti-join does not fail with an error: it fails by giving fewer rows than it should.
Material 910 is different: it does have a copy (EJ-3094), but that copy has never gone out. All three cases —no copies, and copies that never went out— fall into the same result, which is exactly what the task asked for.
Exercise 7: SELF JOIN
Difficulty: Intermediate
Task. For an "if you liked this, you will like that" campaign, we need the pairs of materials by the same author. Each pair must appear only once (we do not want to see A-B and also B-A) and no material must be paired with itself. Show the author and the two titles.
Hint. The condition m1.material_id < m2.material_id solves both problems at once.
Solution
SELECT a.first_name || ' ' || a.last_name AS author,
m1.title AS title_1,
m2.title AS title_2
FROM materials m1
INNER JOIN materials m2
ON m2.author_id = m1.author_id
AND m1.material_id < m2.material_id
INNER JOIN authors a ON a.author_id = m1.author_id
ORDER BY a.last_name, m1.material_id, m2.material_id;Expected result
| author | title_1 | title_2 |
|---|---|---|
| Svetlana Alexievich | Documentary: The Voice of Chernobyl | Voices from Chernobyl |
| Ken Follett | The Pillars of the Earth | World Without End |
| Ken Follett | The Pillars of the Earth | The Pillars of the Earth (series) |
| Ken Follett | World Without End | The Pillars of the Earth (series) |
| Haruki Murakami | Norwegian Wood | Kafka on the Shore |
Explanation. A SELF JOIN is a JOIN of a table with itself; the only thing that makes it possible are the mandatory aliases (m1, m2), which turn one table into two independent "copies" for the engine.
The condition m1.material_id < m2.material_id does two jobs:
- With
<>in its place you would get 10 rows: each pair duplicated in both orders. - With no additional condition at all (only
m2.author_id = m1.author_id) you would get 15 rows: the previous 10 plus the 5 of each material with itself.
Notice too that materials 910 and 911 (the magazines, with a null author_id) do not appear, and that is correct: NULL = NULL is not true, so two rows with an unknown author never pair up. That behavior, which is a nuisance in other contexts, is exactly what we want here: we do not know whether they are by the same author.
Block C — Intermediate: aggregation and grouping
Exercise 8: Aggregates and GROUP BY on one and two columns, with HAVING
Difficulty: Intermediate
Task. Three inventory and finance reports:
- (a) Number of copies per branch, from highest to lowest.
- (b) Number of copies per branch and status.
- (c) Number, total amount and average amount of fines by reason, showing only the reasons with more than one fine.
Solution
-- (a) GROUP BY on one column
SELECT b.name AS branch, count(*) AS copies
FROM copies c
JOIN branches b ON b.branch_id = c.branch_id
GROUP BY b.name
ORDER BY copies DESC;
-- (b) GROUP BY on two columns
SELECT b.name AS branch, c.status, count(*) AS n
FROM copies c
JOIN branches b ON b.branch_id = c.branch_id
GROUP BY b.name, c.status
ORDER BY b.name, c.status;
-- (c) Aggregates + HAVING
SELECT reason,
count(*) AS n_fines,
sum(amount) AS total,
round(avg(amount), 2) AS average
FROM fines
GROUP BY reason
HAVING count(*) > 1
ORDER BY total DESC;Expected result
(a)
| branch | copies |
|---|---|
| Central | 8 |
| North | 3 |
| South | 2 |
| East | 2 |
(b)
| branch | status | n |
|---|---|---|
| Central | available | 4 |
| Central | lost | 1 |
| Central | on_loan | 3 |
| East | reserved | 1 |
| East | withdrawn | 1 |
| North | available | 2 |
| North | on_loan | 1 |
| South | available | 2 |
(c)
| reason | n_fines | total | average |
|---|---|---|---|
| late_return | 5 | 15.00 | 3.00 |
| damage | 2 | 10.50 | 5.25 |
Explanation. The point to internalize is the logical order of execution we saw in 02-05: FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY.
Two practical rules follow from it:
WHEREfilters rows before grouping;HAVINGfilters groups afterwards.WHERE count(*) > 1is a syntax error, and not out of caprice: when theWHEREis evaluated the groups do not exist yet.- Everything in the
SELECTthat is not inside an aggregate function has to be in theGROUP BY. If in (a) you addedb.branch_idto theSELECTwithout adding it to theGROUP BY, PostgreSQL would raise an error. (Curiously it would accept it if you grouped byb.branch_id, because it is a primary key and functionally determinesb.name: PostgreSQL recognizes that dependency. SQLite and MySQL in loose mode never raise an error and return an arbitrary value, which is much worse.)
In (c), the reason loss disappears because of the HAVING despite being the most expensive fine (€24.00). It is a reminder that a badly chosen HAVING can hide exactly what matters.
round(avg(...), 2) requires the argument to be numeric; since amount is NUMERIC(8,2), it works. If the column were double precision, PostgreSQL would demand round(avg(amount)::numeric, 2).
Exercise 9: COUNT(*) versus COUNT(column) in a LEFT JOIN
Difficulty: Intermediate
Task. We want the number of loans of each member, including the members who have never borrowed anything (they must appear with 0). Write the query and explain why count(*) would give an incorrect result.
Hint. Count what the LEFT JOIN did not find.
Solution
SELECT me.member_id,
me.last_name,
count(l.loan_id) AS n_loans, -- correct
count(*) AS wrongly_counted -- incorrect, just to see it
FROM members me
LEFT JOIN loans l ON l.member_id = me.member_id
GROUP BY me.member_id, me.last_name
ORDER BY n_loans DESC, me.member_id;Expected result
| member_id | last_name | n_loans | wrongly_counted |
|---|---|---|---|
| 14 | Alsina | 5 | 5 |
| 15 | Pereda | 4 | 4 |
| 11 | Ferrán | 3 | 3 |
| 12 | Rovira | 3 | 3 |
| 16 | Bastos | 3 | 3 |
| 13 | Mestre | 2 | 2 |
| 17 | Vilanova | 0 | 1 |
| 18 | Quintana | 0 | 1 |
Explanation. This is probably the most expensive trap in reporting SQL, because it produces no error at all: it produces a plausible, wrong number.
count(*)counts rows. After theLEFT JOIN, Óscar Vilanova generates one row —his own, with every column ofloansset toNULL—, socount(*)returns 1.count(l.loan_id)counts non-null values of that column. In Óscar's row,l.loan_idisNULL, so it counts nothing: 0.
The rule worth memorizing: in a LEFT JOIN, always count a column of the right-hand table, and let that column be its primary key. If you counted count(l.return_date), you would get the number of loans already returned (Marta Alsina would give 3 instead of 5), which is another perfectly valid question... but not the one you were asked.
The same applies to sum(): sum(amount) over a group with no rows returns NULL, not 0. If the report is going to be shown on screen, wrap it: COALESCE(sum(amount), 0).
Exercise 10: Ranking of the most-loaned materials
Difficulty: Intermediate
Task. The five most-loaned materials of the whole history, with the title, the author's name (or (no author) if there is none) and the number of loans. Break ties alphabetically by title.
Hint. The loan points at the copy, not at the material: you have to go up one step.
Solution
SELECT m.title,
COALESCE(a.first_name || ' ' || a.last_name, '(no author)') AS author,
count(*) AS n_loans
FROM loans l
JOIN copies c ON c.copy_id = l.copy_id
JOIN materials m ON m.material_id = c.material_id
LEFT JOIN authors a ON a.author_id = m.author_id
GROUP BY m.material_id, m.title, a.first_name, a.last_name
ORDER BY n_loans DESC, m.title ASC
LIMIT 5;Expected result
| title | author | n_loans |
|---|---|---|
| The Map of Time | Félix J. Palma | 5 |
| Kafka on the Shore | Haruki Murakami | 3 |
| The Pillars of the Earth | Ken Follett | 3 |
| The Frozen Heart | Almudena Grandes | 2 |
| The Pillars of the Earth (series) | Ken Follett | 2 |
Explanation. Three deliberate decisions in this query:
- Group by
m.material_id, not bym.title. If two different materials shared a title —perfectly possible: a print edition and an audiobook one— grouping by the title would melt them into a single row. Grouping by the primary key and adding the title to theGROUP BYso that it can be projected is the safe pattern. LEFT JOIN authors, notINNER JOIN. Magazines 910 and 911 have a nullauthor_id. It does not show here because they are not among the most loaned, but anINNER JOINwould silently expel them from any future ranking.COALESCEon the whole concatenation, not on each piece.a.first_name || ' ' || a.last_namewith a nulla.first_namereturns a wholeNULL, because any concatenation withNULLisNULL. That is why theCOALESCEwraps the already-assembled expression.
The tie between "Kafka on the Shore" and "The Pillars of the Earth" (3 loans each) is resolved by title: K before T. Without that second criterion, the order between the two would not be guaranteed and the "top 5" could change from one run to the next.
This ranking is a global LIMIT 5. The "most loaned in each branch" version needs window functions and you will see it in 07-04.
Block D — Advanced: subqueries, derived tables and CTEs
Exercise 11: Scalar subquery, IN, EXISTS, NOT EXISTS and correlated
Difficulty: Advanced
Task. Five questions, each one with the tool that fits it:
- (a) Materials older than the average year of the catalog (scalar subquery).
- (b) Members with some pending fine, using
IN. - (c) Members with some loan not returned, using
EXISTS. - (d) Branches with no lost copy, using
NOT EXISTS. - (e) For each member, the date of their last loan (correlated subquery); members with no loans must come out with
NULL.
Solution
-- (a) Scalar: the subquery returns a single value
SELECT title, publication_year
FROM materials
WHERE publication_year < (SELECT avg(publication_year) FROM materials)
ORDER BY publication_year;
-- (b) IN: the subquery returns a list of values
SELECT member_id, first_name, last_name
FROM members
WHERE member_id IN (SELECT member_id FROM fines WHERE status = 'pending')
ORDER BY member_id;
-- (c) EXISTS: the subquery only says yes or no
SELECT me.member_id, me.first_name, me.last_name
FROM members me
WHERE EXISTS (SELECT 1 FROM loans l
WHERE l.member_id = me.member_id AND l.return_date IS NULL)
ORDER BY me.member_id;
-- (d) NOT EXISTS: anti-join in subquery form
SELECT b.branch_id, b.name
FROM branches b
WHERE NOT EXISTS (SELECT 1 FROM copies c
WHERE c.branch_id = b.branch_id AND c.status = 'lost')
ORDER BY b.branch_id;
-- (e) Correlated in the SELECT
SELECT me.member_id,
me.last_name,
(SELECT max(l.loan_date) FROM loans l
WHERE l.member_id = me.member_id) AS last_loan
FROM members me
ORDER BY me.member_id;Expected result
(a) The catalog average is 2009.58, so 6 rows come out: Norwegian Wood (1987), The Pillars of the Earth (1989), Kafka on the Shore (2002), The Frozen Heart (2007), World Without End (2007), The Map of Time (2008).
(b) Members 13 (Mestre), 14 (Alsina) and 15 (Pereda).
(c) Members 13, 14, 15 and 16.
(d)
| branch_id | name |
|---|---|
| 2 | North |
| 3 | South |
| 4 | East |
(e)
| member_id | last_name | last_loan |
|---|---|---|
| 11 | Ferrán | 2026-07-05 |
| 12 | Rovira | 2026-07-10 |
| 13 | Mestre | 2026-06-15 |
| 14 | Alsina | 2026-07-20 |
| 15 | Pereda | 2026-07-22 |
| 16 | Bastos | 2026-06-10 |
| 17 | Vilanova | (NULL) |
| 18 | Quintana | (NULL) |
Explanation. Each form of subquery answers a different form of question:
| Form | What the subquery returns | When to use it |
|---|---|---|
| Scalar | One value | Comparing each row with a global aggregate |
IN (...) |
A column of values | Membership of a known set |
EXISTS (...) |
Nothing, only yes/no | "At least one exists", no matter how many |
NOT EXISTS (...) |
Nothing, only yes/no | "None exists" (anti-join) |
| Correlated | One value for each outer row | A datum computed row by row |
Two warnings worth their weight in debugging hours:
NOT INandNULLs do not get along. If you wroteWHERE branch_id NOT IN (SELECT branch_id FROM copies WHERE status = 'lost')and that subquery could return aNULL, the result would be zero rows, always, becausex NOT IN (1, NULL)evaluates toNULL.NOT EXISTSdoes not have that problem: that is why it is the recommended form for anti-joins with a subquery.SELECT 1insideEXISTSis neither superstition nor an optimization: it is a statement of intent.EXISTSdoes not care what you project —the engine does not even evaluate it—, and writingSELECT 1makes it clear to the next reader that the columns do not matter.
In (e), the correlated subquery returns NULL for members with no loans because max() over an empty set is NULL. That is exactly what the task asked for; if you wanted a text, COALESCE(..., 'never') would require converting the date to text first.
Exercise 12: Derived table, WITH CTE and UNION
Difficulty: Advanced
Task. Three queries that reorganize the problem before answering it:
- (a) The average number of loans per active member, using a derived table.
- (b) One card per branch with its number of members and its number of copies, using two CTEs.
- (c) A unified contact list: all the emails of active members and of speakers, in a single list, with a column stating where each one comes from.
Hint. In (b), do not try to do it with two direct JOINs on branches: the counts would multiply each other.
Solution
-- (a) Derived table: we aggregate and then aggregate over the result
SELECT round(avg(n), 2) AS avg_loans_per_member
FROM (
SELECT me.member_id, count(l.loan_id) AS n
FROM members me
LEFT JOIN loans l ON l.member_id = me.member_id
WHERE me.active
GROUP BY me.member_id
) AS counts;
-- (b) Two independent CTEs, joined at the end
WITH members_per_branch AS (
SELECT branch_id, count(*) AS n_members
FROM members
GROUP BY branch_id
),
copies_per_branch AS (
SELECT branch_id, count(*) AS n_copies
FROM copies
GROUP BY branch_id
)
SELECT b.name AS branch,
COALESCE(mb.n_members, 0) AS members,
COALESCE(cb.n_copies, 0) AS copies
FROM branches b
LEFT JOIN members_per_branch mb ON mb.branch_id = b.branch_id
LEFT JOIN copies_per_branch cb ON cb.branch_id = b.branch_id
ORDER BY b.branch_id;
-- (c) UNION of two queries with the same shape
SELECT 'member' AS source, first_name, last_name, email
FROM members WHERE active
UNION
SELECT 'speaker', first_name, last_name, email
FROM speakers
ORDER BY source, last_name;Expected result
(a) 2.86 — there are 7 active members (all but Óscar Vilanova) adding up to 20 loans: 20 / 7 = 2.857...
(b)
| branch | members | copies |
|---|---|---|
| Central | 3 | 8 |
| North | 2 | 3 |
| South | 2 | 2 |
| East | 1 | 2 |
(c) 10 rows: 7 active members and 3 speakers (Calduch, Lemus, Marchetti).
Explanation. All three are variants of the same move: compute an intermediate result and query it as if it were a table.
In (a), the double aggregation is mandatory. avg(count(*)) does not exist: aggregate functions cannot be nested. You have to aggregate once (loans per member), materialize that result as a derived table and aggregate again. Notice the AS counts: in PostgreSQL a derived table needs an alias or the engine raises an error, even if you never use it.
In (b) lies the important lesson. If you wrote this:
-- WRONG: the counts multiply
SELECT b.name, count(DISTINCT me.member_id), count(DISTINCT c.copy_id)
FROM branches b
LEFT JOIN members me ON me.branch_id = b.branch_id
LEFT JOIN copies c ON c.branch_id = b.branch_id
GROUP BY b.name;...the count(DISTINCT ...) would save you by a miracle, but a count(*) would give 3 × 8 = 24 for Central. The JOIN of two independent branches against the same table produces a Cartesian product inside each branch. This is the most expensive aggregation mistake there is because the result looks reasonable. The robust solution is to aggregate each branch separately —in CTEs or in subqueries— and join afterwards.
In (c), UNION removes duplicates and UNION ALL does not. Here no duplicates are possible because the first column already tells them apart, so UNION ALL would be faster. Both branches must have the same number of columns and compatible types, and the names are always set by the first branch: that is why the aliases are only needed at the top. The ORDER BY goes once only, at the end, and applies to the united set.
Block E — Advanced: real management reports
Exercise 13: Fine collection by month and payment method
Difficulty: Advanced
Task. The municipal audit office asks for the fine collection: by month and payment method, the number of payments and the total amount, plus a channel column worth 'in person' for cash and card and 'online' for gateway. Add a final query with the total outstanding (fines in pending status).
Hint. to_char(payment_date, 'YYYY-MM') groups by month in PostgreSQL.
Solution
-- Collection by month and method
SELECT to_char(pm.payment_date, 'YYYY-MM') AS month_,
pm.method,
CASE WHEN pm.method IN ('cash', 'card') THEN 'in person'
WHEN pm.method = 'gateway' THEN 'online'
ELSE 'unknown'
END AS channel,
count(*) AS n_payments,
sum(pm.amount) AS collected
FROM payments pm
GROUP BY 1, 2, 3
ORDER BY month_, pm.method;
-- Outstanding
SELECT count(*) AS pending_fines, sum(amount) AS pending_amount
FROM fines
WHERE status = 'pending';Expected result
| month_ | method | channel | n_payments | collected |
|---|---|---|---|---|
| 2026-03 | cash | in person | 1 | 2.20 |
| 2026-04 | card | in person | 1 | 3.00 |
| 2026-05 | card | in person | 1 | 4.00 |
| 2026-05 | gateway | online | 1 | 2.50 |
Total collected: €11.70. Outstanding: 3 fines worth €30.60.
Explanation. The report has three fine points:
- It groups by
payments, not byfines. A fine can be collected in several installments: fine 5 (€6.50, damage, Nuria Bastos) was collected in two payments, €4.00 by card in May and €2.50 by gateway also in May. If the report were built onfines, that case would be counted wrongly as soon as the two installments fell in different months. The question "how much came into the till in March" is always answered on the table of movements, not on the table of debts. GROUP BY 1, 2, 3groups by position in theSELECT. It is convenient, but fragile: if you insert a column at the beginning, the grouping changes without warning. In queries that are going to last, write the names or repeat the wholeCASEexpression in theGROUP BY.- The
waivedandvoidedfines do not appear anywhere in the report, and that is correct: they have not been collected and never will be. Fine 6 (€4.00, waived) and fine 8 (€3.20, voided) add up to €7.20 that is neither income nor debt. A report that mixed them in with the pending ones would inflate the collection forecast by 23%.
In SQLite, to_char does not exist: you use strftime('%Y-%m', payment_date).
Exercise 14: Overdue loans with the surcharge computed
Difficulty: Advanced
Task. As of August 2, 2026, list the loans overdue and not returned: member, code and title, due date, days late and the surcharge at a rate of €0.20/day with a cap of €15.00. Order by surcharge descending.
Hint. Subtracting two DATEs in PostgreSQL returns an integer number of days. The cap is applied with LEAST.
Solution
SELECT me.first_name || ' ' || me.last_name AS member,
c.code,
m.title,
l.due_date AS due,
(DATE '2026-08-02' - l.due_date) AS days_late,
LEAST((DATE '2026-08-02' - l.due_date) * 0.20, 15.00) AS surcharge
FROM loans l
JOIN members me ON me.member_id = l.member_id
JOIN copies c ON c.copy_id = l.copy_id
JOIN materials m ON m.material_id = c.material_id
WHERE l.return_date IS NULL
AND l.due_date < DATE '2026-08-02'
ORDER BY surcharge DESC, days_late DESC;Expected result
| member | code | title | due | days_late | surcharge |
|---|---|---|---|---|---|
| Sonia Mestre | EJ-3088 | Norwegian Wood | 2026-04-26 | 98 | 15.00 |
| Marta Alsina | EJ-3093 | Voices from Chernobyl | 2026-05-31 | 63 | 12.60 |
| Nuria Bastos | EJ-3087 | The Frozen Heart | 2026-07-01 | 32 | 6.40 |
Explanation. Three things to look at with a magnifying glass:
- The
WHEREcarries two conditions and both are indispensable.return_date IS NULLselects the open loans;due_date < '2026-08-02'selects the overdue ones. With only the first, loans 17 and 19 (due on August 10 and 12) would appear with negative days late and a negative surcharge: the library owing money to the member. - The cap is applied and it shows. Without
LEAST, Sonia Mestre would pay €19.60 (98 × 0.20). The regulation fixes €15.00, andLEAST(a, b)returns the smaller of the two. In MySQL the function is also calledLEAST; in SQLite it isMIN(a, b)with two arguments, which is not the aggregate functionMIN()despite the name. - Date arithmetic is engine-specific. In PostgreSQL,
date - datereturnsinteger(days). In SQLite you have to writejulianday('2026-08-02') - julianday(due_date), and in MySQLDATEDIFF('2026-08-02', due_date). It is one of the first places where a portable query stops being portable.
A consistency note: copy EJ-3093 is recorded as lost and its loan is still open. It already has a loss fine (€24.00), so in the real process it should be excluded from the late-return surcharges. Adding AND c.status <> 'lost' is a perfectly defensible business decision; the task did not ask for it, but a report handed to management should document it.
Exercise 15: Event occupancy by branch
Difficulty: Advanced
Task. For the events already held, compute per branch: number of events, offered seats, occupied seats and occupancy percentage with one decimal. Only registrations in status confirmed or attended count as occupied (the cancelled ones and those on the waiting list do not).
Hint. If you join events with registrations and add up offered_seats, the result will be inflated. Aggregate by event first.
Solution
WITH event_occupancy AS (
SELECT ev.event_id,
ev.room_id,
ev.offered_seats,
COALESCE(sum(r.occupied_seats) FILTER (
WHERE r.status IN ('confirmed', 'attended')), 0) AS occupied
FROM events ev
LEFT JOIN registrations r ON r.event_id = ev.event_id
WHERE ev.status = 'held'
GROUP BY ev.event_id, ev.room_id, ev.offered_seats
)
SELECT b.name AS branch,
count(*) AS events,
sum(eo.offered_seats) AS offered,
sum(eo.occupied) AS occupied,
round(100.0 * sum(eo.occupied) / sum(eo.offered_seats), 1) AS pct_occupancy
FROM event_occupancy eo
JOIN rooms ro ON ro.room_id = eo.room_id
JOIN branches b ON b.branch_id = ro.branch_id
GROUP BY b.branch_id, b.name
ORDER BY pct_occupancy DESC;Expected result
| branch | events | offered | occupied | pct_occupancy |
|---|---|---|---|---|
| South | 1 | 8 | 5 | 62.5 |
| North | 1 | 20 | 9 | 45.0 |
| East | 1 | 10 | 4 | 40.0 |
| Central | 2 | 52 | 15 | 28.8 |
Explanation. This exercise brings together almost the whole block in a single query, and its value is in the CTE.
Why the CTE is mandatory. If you added up offered_seats directly over the JOIN of events with registrations, each event would appear as many times as it has registrations. Event 101 has 5 registrations, so its 12 offered seats would be counted 5 times: 60. Central would go from 52 to 100 offered seats and the occupancy would fall from 28.8% to 15%. The mistake produces no symptom at all: the numbers are still reasonable whole numbers. The general rule is: when a report adds up an attribute of the "one" side and another attribute of the "many" side, the one from the "one" side has to be aggregated separately.
The status filter goes in the FILTER, not in the WHERE. sum(...) FILTER (WHERE ...) applies the condition only to the aggregate, leaving the other rows of the group untouched. If you put r.status IN ('confirmed','attended') in the WHERE of the CTE, an event whose registrations were all cancelled would disappear from the report instead of appearing with 0 occupied. FILTER is standard SQL and PostgreSQL supports it; the portable equivalent is sum(CASE WHEN r.status IN ('confirmed','attended') THEN r.occupied_seats ELSE 0 END), which also works in SQLite and MySQL.
100.0 and not 100. If you write 100 * sum(occupied) / sum(offered) with integers, PostgreSQL does integer division and Central would give 28 instead of 28.8, and a case like 5/8 would give 62 instead of 62.5. Multiplying by 100.0 first forces decimal arithmetic. This mistake shows up in production all the time.
The LEFT JOIN is still necessary even though in this data every held event has registrations: if tomorrow an event is held that nobody signed up for, with an INNER JOIN it would disappear from the denominator and the average occupancy would come out artificially high.
Common Mistakes and Tips
1. = NULL instead of IS NULL. It does not raise an error: it gives zero rows. Every time a query returns an unexpectedly empty set, this is the first suspect.
2. count(*) in a LEFT JOIN. It counts 1 where it should count 0. Always count the primary key of the right-hand table.
3. Adding up attributes of the "one" side over a JOIN with the "many" side. The totals get multiplied by the number of child rows. Aggregate each branch in its own CTE or subquery.
4. HAVING and WHERE swapped. WHERE filters rows before grouping, HAVING filters groups afterwards. Putting a condition in HAVING that could go in WHERE works but is slower: it forces the grouping of rows that are going to be discarded.
5. ORDER BY with no tiebreaker. A LIMIT over a non-deterministic order returns different rows in different runs. Always add a unique criterion as the last tiebreaker.
6. Integer division. 100 * a / b with integers truncates. Force decimals with 100.0 or with ::numeric.
7. NOT IN with subqueries that can return NULL. It returns zero rows, always. Use NOT EXISTS.
8. Confusing the member's branch with the copy's branch. They are two different foreign keys towards the same table. When the question says "by branch", find out which branch before writing the JOIN.
9. Forgetting the ON of a JOIN. A silent Cartesian product. With small tables it looks as though it works; with 40,000 copies it does not.
10. Concatenating with NULL. 'a' || NULL is NULL. Wrap the whole expression in COALESCE, not each piece.
A method tip. Build big queries from the inside out: write the FROM with its JOINs and a SELECT * first, check the number of rows, add the WHERE, check again, and only at the end group and project. 90% of aggregation mistakes are caught by looking at how many rows there are before grouping.
A reading tip. When you inherit somebody else's query, read it in the logical order of execution (FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY), not in the order in which it is written. That is the order in which the engine understands it.
Exercises
No hints and more demanding. Write each one as a single query.
Exercise A: Financial card for a member
For Marta Alsina (member 14), return a single row with: last name, total number of loans, number of loans returned late, number of open loans, total amount of her fines, amount actually paid and outstanding amount.
Exercise B: Materials present in three or more branches
List the materials that have copies in three or more distinct branches, with the title, the number of branches and the total number of copies. A material with five copies in the same branch does not count.
Exercise C: Members at risk
Return a list of members at risk with a reason column. A member is at risk if they have some overdue loan not returned as of 2026-08-02 (reason 'overdue loan') or if they accumulate more than €10 in pending fines (reason 'high debt'). A member can appear for both reasons.
Solutions
Solution A
SELECT me.last_name,
count(l.loan_id) AS n_loans,
count(*) FILTER (WHERE l.return_date > l.due_date) AS late,
count(*) FILTER (WHERE l.return_date IS NULL) AS open_loans,
(SELECT COALESCE(sum(f.amount), 0) FROM fines f
WHERE f.member_id = me.member_id) AS fines_total,
(SELECT COALESCE(sum(pm.amount), 0) FROM payments pm
JOIN fines f ON f.fine_id = pm.fine_id
WHERE f.member_id = me.member_id) AS paid,
(SELECT COALESCE(sum(f.amount), 0) FROM fines f
WHERE f.member_id = me.member_id AND f.status = 'pending') AS pending
FROM members me
LEFT JOIN loans l ON l.member_id = me.member_id
WHERE me.member_id = 14
GROUP BY me.member_id, me.last_name;| last_name | n_loans | late | open_loans | fines_total | paid | pending |
|---|---|---|---|---|---|---|
| Alsina | 5 | 1 | 2 | 26.20 | 2.20 | 24.00 |
The three financial figures go in scalar subqueries precisely to avoid the problem of exercise 15: if you joined fines and payments in the same JOIN as loans, each fine would be repeated five times (one per loan) and the total would jump from €26.20 to €131.00. The FILTERs over count(*) work here because the LEFT JOIN with loans does not duplicate anything: each loan is one row.
Solution B
SELECT m.material_id,
m.title,
count(DISTINCT c.branch_id) AS branches,
count(*) AS copies
FROM materials m
JOIN copies c ON c.material_id = m.material_id
GROUP BY m.material_id, m.title
HAVING count(DISTINCT c.branch_id) >= 3
ORDER BY branches DESC, m.title;| material_id | title | branches | copies |
|---|---|---|---|
| 901 | The Pillars of the Earth | 3 | 3 |
Only "The Pillars of the Earth" is spread across three branches (Central, South and East). The DISTINCT inside the count is what tells "three branches" apart from "three copies": without it, material 906 (two copies, in South and Central) would still not sneak in, but a material with three copies in Central would, and that would be a mistake.
Solution C
SELECT me.member_id, me.first_name, me.last_name, 'overdue loan' AS reason
FROM members me
WHERE EXISTS (SELECT 1 FROM loans l
WHERE l.member_id = me.member_id
AND l.return_date IS NULL
AND l.due_date < DATE '2026-08-02')
UNION ALL
SELECT me.member_id, me.first_name, me.last_name, 'high debt'
FROM members me
JOIN fines f ON f.member_id = me.member_id AND f.status = 'pending'
GROUP BY me.member_id, me.first_name, me.last_name
HAVING sum(f.amount) > 10
ORDER BY 1, 4;| member_id | first_name | last_name | reason |
|---|---|---|---|
| 13 | Sonia | Mestre | overdue loan |
| 14 | Marta | Alsina | high debt |
| 14 | Marta | Alsina | overdue loan |
| 16 | Nuria | Bastos | overdue loan |
Marta Alsina appears twice, which is exactly what the task asked for; that is why you have to use UNION ALL and not UNION (although here UNION would give the same, because the reason column already distinguishes the rows). Note that Iván Pereda does not come out: he has a pending fine, but of €1.60, and his open loan is due on August 12. And Sonia Mestre comes out only because of the loan, because her €5.00 outstanding does not reach the threshold.
Conclusion
You have written fifteen queries about BiblioRed that cover all the SQL of module 2, this time without a safety net: projection with aliases, filters with BETWEEN, IN, LIKE and IS NULL, deterministic ordering with LIMIT, DML with the discipline of the prior SELECT, INNER JOIN of two and of four tables, LEFT JOIN with the anti-join pattern, SELF JOIN, aggregation with GROUP BY on one and two columns, HAVING, the difference between COUNT(*) and COUNT(column), the five forms of subquery, derived tables, CTEs, UNION and three management reports that already look like what a service directorate asks for.
More important than the syntax are the three reflexes that should have been installed: look before you modify, check the number of rows before you aggregate and distrust any report that adds up an attribute of the "one" side through a JOIN with the "many" side. None of the three is taught to anybody by an error message, because all three fail in silence.
The next lesson changes muscle. In 07-02, Schema Design Exercises, there will be no tables to query: there will be requirement statements —a video rental store, a course platform, a clinic, a historical fare system— and you will have to build the schema from scratch, going through the ER diagram, the CREATE TABLE and the constraints that encode the business rules. One of the five cases will be an extension of BiblioRed, so that you can see how different it is to design over a schema that already exists and that you cannot break.
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
