This is where you learn. Not by reading these solutions, but by comparing them with yours: seeing where you agree, where you've chosen an equally good route and where you've left out a LEFT JOIN that was needed. If you've arrived here without having attempted the project, go back to 12-03: the solution set read cold teaches you almost nothing. Two warnings: a solution that differs from the reference one can be just as valid —what's marked isn't how closely it resembles this text, but whether it meets 12-02 and whether the decision is justified—, and every result is computed over 12-03's data set (36 loans, 20 copies, 15 members) with DATE '2026-06-30' as the reference date.

Contents

  1. The reference schema: decisions and alternatives
  2. The 15 solved queries
  3. The reference indexes
  4. The view, the procedure and the trigger
  5. Frequent mistakes in this project
  6. Common Mistakes and Tips
  7. Exercises
  8. Conclusion

  1. The reference schema: decisions and alternatives

The complete DDL is in 12-03. Here are the decisions that get marked, each with the alternative that would also be correct and when to choose it.

Reference decision Valid alternative When to choose the alternative
works + copies in two tables, and copies.status without the value on_loan (none) Never. They're DR-05/DR-07, and adding on_loan would duplicate what loans already says
works_authors with a composite PK (work_id, author_id) A surrogate id + UNIQUE (work_id, author_id) If another table had to reference the credit (royalties per author and work). Until that exists, the composite PK expresses the rule better (05-01)
due_date stored, and the loan's state derived from the dates Computing it from the member's type; a status column with a CHECK maintained overnight If the term could change neither by type nor by renewal (here it changes for both reasons), or if states that can't be deduced were needed (in_claim, waived)
fines as a 1-to-0..1 table, and the queue by reservation_date fine_amount/fine_paid columns in loans; a position column maintained by a trigger If the fine were a number with no payment date and no waiver; and if the queue had to be reordered by hand (priorities). With pure FIFO, computing the position is strictly better
A partial unique index for IR-03 An EXCLUDE constraint with btree_gist When historical overlaps also have to be prevented: see below

The strong alternative to IR-03: EXCLUDE

The partial index prevents two active loans of the same copy, but not two past loans that overlap. If that matters —and in a migration of old data it matters a great deal—, PostgreSQL has a constraint for exactly this:

CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE loans ADD CONSTRAINT excl_loans_overlap EXCLUDE USING gist (copy_id WITH =,
    daterange(loan_date, COALESCE(return_date, DATE 'infinity'), '[]') WITH &&);

It reads: "there can't be two rows with the same copy_id whose loan intervals overlap", and the COALESCE(..., 'infinity') turns the open loan into an interval with no end, so it also covers IR-03. It's more powerful and more expensive: it needs an extension, it uses a GiST index and it's no help for WHERE return_date IS NULL. The criterion: a partial index if you only care about the present; EXCLUDE if you're going to load history or if loans are registered retroactively. Both are correct; you have to be able to say which one you chose.

  1. The 15 solved queries

Block A — basics and aggregation (QR-01 to QR-08)

-- QR-01 · Works published since 2015
SELECT w.id, w.title, w.publication_year, w.isbn
FROM   works AS w
WHERE  w.publication_year >= 2015 ORDER BY w.publication_year DESC, w.title;

Nine rows, led by Notebook of Shadows (2024) and The Forest of Names (2023). The key decision is the tie-breaker: a bare ORDER BY publication_year DESC would leave ties at the mercy of the plan and the result would change between runs. And Distributed Networks and Systems shows up, the work with no copies at all: it's catalogued, so it appears in the catalogue.

-- QR-02 · Copies at one branch, with their work and its subject
SELECT c.barcode, w.title, s.name AS subject, c.status, c.acquired_date
FROM   copies AS c   JOIN works AS w ON w.id = c.work_id
JOIN   subjects AS s ON s.id = w.subject_id   JOIN branches AS b ON b.id = c.branch_id
WHERE  b.name = 'Biblioteca Infantil do Parque' ORDER BY w.title, c.barcode;
barcode title subject status acquired_date
ALV-0014 The Girl Who Counted Stars Children available 2024-02-05
ALV-0015 The Girl Who Counted Stars Children available 2024-02-05

(2 of 3 rows.) Two rows with the same title, and it isn't a mistake: they're two different copies of the same work, which is the heart of the project. Anyone who wrote SELECT DISTINCT w.title has lost exactly the information that was being asked for.

-- QR-03 · Active loans, with days out and days late as of the reference date
SELECT l.id, m.name || ' ' || m.last_name AS member, w.title, b.name AS branch,
       l.due_date, DATE '2026-06-30' - l.loan_date AS days_out,
       GREATEST(DATE '2026-06-30' - l.due_date, 0) AS days_late
FROM   loans AS l   JOIN members AS m ON m.id = l.member_id
JOIN   copies AS c ON c.id = l.copy_id   JOIN works AS w ON w.id = c.work_id
JOIN   branches AS b ON b.id = c.branch_id
WHERE  l.return_date IS NULL ORDER BY days_late DESC, l.due_date;
id member title branch due_date days_out days_late
29 Lena Fuentes The Garden of Hours Biblioteca de Vila Nova 2026-05-25 57 36
34 Nuno Barros A Brief History of Alvorada Biblioteca de Vila Nova 2026-06-29 22 1

(2 of 6 rows; Alba Rey is also 1 day late, and the other three are within term, with 0 days late.) Six active loans, and three are of the same work —the three copies of The Garden of Hours are all out, and that's where QR-10's queue comes from—. The key decision is GREATEST(..., 0): without it, loans within term would come out with a negative delay, which means nothing and pollutes any later sum. And return_date IS NULL, never = NULL: 04-03's mistake that returns zero rows without warning.

-- QR-04 · The system's three gaps, in a single result
SELECT 'member without loans' AS gap, m.id, m.name || ' ' || m.last_name AS description
FROM   members AS m LEFT JOIN loans AS l ON l.member_id = m.id   WHERE l.id IS NULL
UNION ALL SELECT 'work without copies', w.id, w.title
FROM   works AS w LEFT JOIN copies AS c ON c.work_id = w.id      WHERE c.id IS NULL
UNION ALL SELECT 'copy never lent', c.id, c.barcode || ' — ' || w.title
FROM   copies AS c JOIN works AS w ON w.id = c.work_id
LEFT   JOIN loans AS l ON l.copy_id = c.id WHERE l.id IS NULL    ORDER BY 1, 2;
gap id description
member without loans 12 Irene Sampaio
work without copies 12 Distributed Networks and Systems

(2 of 7 rows: 3 copies never lent, 1 work without copies and 3 members without loans.) Three different business readings: copies taking up shelf space without ever going out, a catalogued title that hasn't arrived yet, and cards never used. The technique is 03-03's anti-join (LEFT JOIN + WHERE ... IS NULL), equivalent to NOT EXISTS (07-03) and not to NOT IN, which with a NULL in the subquery would silently return zero rows. UNION ALL and not UNION, because there are no duplicates to remove; and the three branches need the same number of columns and compatible types (03-07), hence the gap label.

-- QR-05 · Subjects with 5+ loans and their average duration. Definition: a loan = any
-- row of loans; duration = days until return, or until today if it's still open
SELECT s.name AS subject, COUNT(l.id) AS loans, COUNT(DISTINCT w.id) AS works,
       ROUND(AVG(COALESCE(l.return_date, DATE '2026-06-30')
                 - l.loan_date), 1) AS avg_days
FROM   loans AS l
JOIN   copies AS c ON c.id = l.copy_id   JOIN works AS w ON w.id = c.work_id
JOIN   subjects AS s ON s.id = w.subject_id
GROUP  BY s.id, s.name HAVING COUNT(l.id) >= 5 ORDER BY loans DESC, s.name;

Three subjects make the cut: Fiction (12 loans, 2 works, 32.3 days on average), Children (8, 2, 14.6) and Computing (6, 1, 26.8). The filter goes in HAVING and not in WHERE because it applies to the already aggregated group: WHERE COUNT(*) >= 5 is a syntax error, and it's 04-06's most repeated failure. The six subjects share out the 36 loans (12 + 8 + 6 + 4 + 4 + 2). And the COALESCE in the duration is a definition decision: without it, AVG would ignore the six open loans and Fiction's average would drop, because the three longest loans right now are precisely the ones that haven't come back.

-- QR-06 · Availability by work. Enabled = with status 'available' (excludes repair,
-- lost and withdrawn); available now = enabled minus the ones currently on loan
SELECT w.title, COUNT(c.id) AS copies, COUNT(la.id) AS on_loan,
       COUNT(c.id) FILTER (WHERE c.status = 'available') AS enabled,
       COUNT(c.id) FILTER (WHERE c.status = 'available') - COUNT(la.id) AS available
FROM   works AS w
LEFT   JOIN copies AS c  ON c.work_id = w.id
LEFT   JOIN loans  AS la ON la.copy_id = c.id AND la.return_date IS NULL
GROUP  BY w.id, w.title ORDER BY available, w.title;
title copies on_loan enabled available
Distributed Networks and Systems 0 0 0 0
The Garden of Hours 3 3 3 0

(2 of 12 rows.) The two rows say 0 available for opposite reasons, and an honest report distinguishes them: of the first there isn't a single copy; of the second there are three copies and all three are out on loan. That's why all four columns are published and not just the last one. Three decisions: COUNT(c.id) and not COUNT(*), or the work with no copies would come out with 1; the condition of the LEFT JOIN to loans goes in the ON; and Relational Databases comes out with 3 copies but only 2 enabled, because one is in repair — that difference is exactly what a single counter couldn't express.

-- QR-07 · Works signed by more than one author, in credit order
SELECT w.title, COUNT(*) AS n_authors,
       string_agg(a.name || ' ' || a.last_name, ', ' ORDER BY wa.credit_order) AS authors
FROM   works AS w JOIN works_authors AS wa ON wa.work_id = w.id
JOIN   authors AS a ON a.id = wa.author_id
GROUP  BY w.id, w.title HAVING COUNT(*) > 1 ORDER BY w.title;
title n_authors authors
A Brief History of Alvorada 2 Ruy Castelo, Tomás Vega
Relational Databases 2 Pere Aymà, Nora Ibáñez

(2 of 3 rows; "The Forest of Names", by Clara Meireles and Ada Quiroga, is missing.) The ORDER BY wa.credit_order inside the string_agg is the key decision, and almost everybody skips it: without it, the order of the authors inside the cell is whatever the engine feels like, and a cover credited "Aymà and Ibáñez" could come out reversed. It's the reason works_authors has a credit_order column. And notice that the bridge table carries data of its own (role and credit_order): that turns it into a fully fledged entity and not a mere pair of keys.

-- QR-08 · Fines by member type. A fine = a row of fines, which only exists after the return
-- (BR-10); outstanding = with no paid_date. Does NOT include the potential debt of overdue loans
SELECT m.type, COUNT(f.id) AS fines,
       COALESCE(SUM(f.amount), 0)                                        AS total_amount,
       COALESCE(SUM(f.amount) FILTER (WHERE f.paid_date IS NOT NULL), 0) AS collected,
       COALESCE(SUM(f.amount) FILTER (WHERE f.paid_date IS NULL), 0)     AS outstanding
FROM   members AS m
LEFT   JOIN loans AS l ON l.member_id = m.id   LEFT JOIN fines AS f ON f.loan_id = l.id
GROUP  BY m.type ORDER BY total_amount DESC;
type fines total_amount collected outstanding
general 5 25.80 12.40 13.40
child 1 1.40 1.40 0.00
senior 0 0.00 0.00 0.00

The €13.40 outstanding belongs to a single member, Diego Andrade, and it's exactly what pushes him over BR-11's €10 threshold and explains his suspended status. The control figure: 6 fines out of 30 returned loans is a late rate of 20.00 %, and 25.80 + 1.40 = €27.20 is the system total. The COALESCE is essential —without it, the senior row would show NULL in a money column, which somebody will read as "no data" (06-04)— and that row must appear: the LEFT JOIN is what saves it.

Block B — subqueries, queues, windows and recursion (QR-09 to QR-15)

-- QR-09 · Members with more loans than the average for their type
SELECT m.id, m.name || ' ' || m.last_name AS member, m.type,
       (SELECT COUNT(*) FROM loans AS l WHERE l.member_id = m.id) AS loans,
       ROUND((SELECT COUNT(l2.id)::numeric / COUNT(DISTINCT m2.id) FROM members AS m2
              LEFT JOIN loans AS l2 ON l2.member_id = m2.id
              WHERE m2.type = m.type), 2)                         AS type_average
FROM   members AS m
WHERE  (SELECT COUNT(*) FROM loans AS l WHERE l.member_id = m.id)
     > (SELECT COUNT(l2.id)::numeric / COUNT(DISTINCT m2.id) FROM members AS m2
        LEFT JOIN loans AS l2 ON l2.member_id = m2.id WHERE m2.type = m.type)
ORDER  BY m.type, loans DESC, m.id;
id member type loans type_average
1 Marta Coelho general 4 2.56
5 Alba Rey child 4 2.33

(2 of 10 rows: 6 general, 2 child and 2 senior.) The subquery is correlated because of the WHERE m2.type = m.type: it's evaluated once per member, with their type (07-02). And the detail that decides whether the figure is right is COUNT(l2.id) versus COUNT(*): with COUNT(*), members with no loans would each contribute a phantom row and the general average would come out at 2.67 instead of 2.56 — close enough for nobody to notice. An equally valid and more readable alternative: a CTE with the averages per type and a JOIN against it; with fifteen members it makes no difference, with fifteen thousand the CTE is evaluated once instead of once per row.

-- QR-10 · The waiting reservation queue, with each member's position
SELECT w.title, m.name || ' ' || m.last_name AS member, r.reservation_date, b.name AS pickup_at,
       ROW_NUMBER() OVER (PARTITION BY r.work_id ORDER BY r.reservation_date, r.id) AS position
FROM   reservations AS r
JOIN   works AS w ON w.id = r.work_id       JOIN members AS m ON m.id = r.member_id
JOIN   branches AS b ON b.id = r.branch_id  WHERE r.status = 'waiting'
ORDER  BY w.title, position;
title member reservation_date pickup_at position
The Garden of Hours Nuno Barros 2026-06-10 Biblioteca Central de Alvorada 1
The Garden of Hours Manuel Otero 2026-06-18 Biblioteca Central de Alvorada 2
The Garden of Hours Óscar Vilar 2026-06-22 Biblioteca de Vila Nova 3

The position isn't in any column: ROW_NUMBER() computes it. That's the whole solution to the queue problem, and that's why PARTITION BY r.work_id is mandatory: each work has its own queue and the numberings mustn't get mixed up. The r.id as a second criterion isn't optional —two reservations on the same day would end up in arbitrary order, and a member's position would change between two queries—. It's ROW_NUMBER and not RANK on purpose: in a queue there can't be two firsts. And the WHERE leaves out the reservation already collected and the expired one, which are history.

-- QR-11 · The 3 most-borrowed works at each branch
SELECT b.name AS branch, t.rank_, t.title, t.loans
FROM   branches AS b LEFT JOIN LATERAL (
           SELECT w.title, COUNT(*) AS loans,
                  ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC, w.title) AS rank_
           FROM   copies AS c JOIN works AS w ON w.id = c.work_id
           JOIN   loans AS l ON l.copy_id = c.id
           WHERE  c.branch_id = b.id   GROUP BY w.id, w.title
           ORDER  BY loans DESC, w.title LIMIT 3) AS t ON TRUE
ORDER  BY b.id, t.rank_;
branch rank_ title loans
Biblioteca Central de Alvorada 1 The Garden of Hours 6
Biblioteca Infantil do Parque 1 The Girl Who Counted Stars 4

(2 of 8 rows; at Vila Nova first place goes to "Relational Databases", with 2.) LATERAL is what lets the subquery see b.id from the outer row; without it, a LIMIT 3 inside an ordinary subquery would give the top 3 for the whole system, repeated at all three branches (07-04). The LEFT JOIN LATERAL ... ON TRUE instead of CROSS JOIN LATERAL is what saves a branch with no loans. An equally valid alternative: a CTE with a partitioned ROW_NUMBER() and a WHERE rank_ <= 3 outside — more portable, because LATERAL isn't in every engine. LATERAL wins when the outer table is small and the inner one huge, because it only reads what it needs from each group.

-- QR-12 · Loans per month for the last 12 months, with no gaps
WITH calendar AS (SELECT generate_series(DATE '2025-07-01', DATE '2026-06-01',
                                         INTERVAL '1 month')::date AS month),
monthly AS (SELECT date_trunc('month', loan_date)::date AS month, COUNT(*) AS n
            FROM loans GROUP BY 1)
SELECT to_char(c.month, 'YYYY-MM') AS month, COALESCE(mo.n, 0) AS loans,
       SUM(COALESCE(mo.n, 0)) OVER (ORDER BY c.month) AS running_total,
       ROUND(AVG(COALESCE(mo.n, 0)) OVER (ORDER BY c.month
             ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg_3
FROM   calendar AS c LEFT JOIN monthly AS mo ON mo.month = c.month ORDER BY c.month;
-- Without the calendar, the series would have 10 rows instead of 12 and would start in September
month loans running_total moving_avg_3
2025-07 0 0 0.00
2026-06 4 36 4.00

(2 of 12 rows: the first and the last; 2025-07 and 2025-08 are empty.) The two months with zero are the reason for the query. Without the calendar, a GROUP BY over loans would return ten rows and the chart would start in September as if the service hadn't existed before; the LEFT JOIN against generate_series makes them visible (11-01). The COALESCE isn't cosmetic: without it the window would drag a NULL along and the running total would be useless from the first empty row onwards. The running total closes at 36, the total number of loans: the control figure that validates the whole series (11-04).

-- QR-13 · The 3 heaviest readers at each branch, with an explicit tie-breaker
WITH ranking AS (
    SELECT b.id AS branch_id, b.name AS branch, m.name || ' ' || m.last_name AS member,
           COUNT(l.id) AS loans, MAX(l.loan_date) AS last_loan,
           ROW_NUMBER() OVER (PARTITION BY b.id ORDER BY COUNT(l.id) DESC,
                              MAX(l.loan_date) DESC, m.id) AS rank_
    FROM   members AS m
    JOIN   branches AS b ON b.id = m.branch_id   LEFT JOIN loans AS l ON l.member_id = m.id
    WHERE  m.status <> 'closed'   GROUP BY m.id, b.id, b.name, m.name, m.last_name)
SELECT branch, rank_, member, loans, last_loan FROM ranking
WHERE  rank_ <= 3 ORDER BY branch_id, rank_;
branch rank_ member loans last_loan
Biblioteca Central de Alvorada 1 Iván Losada 4 2026-05-12
Biblioteca Infantil do Parque 3 Carla Nieto 0 (null)

(2 of 9 rows; Marta Coelho is second at the Central, also with 4.) The tie-breaker is the decision that gets marked. Iván and Marta have the same 4 loans: with RANK() both would be first, and at Vila Nova, where there's a three-way tie on 3, a "top 3" would return three firsts. With ROW_NUMBER() and the criterion most loans → most recent → lowest id, the order is total and stable. Neither of the two is incorrect —RANK is what you want in a sports table—, but you have to choose deliberately and say so. And the second row is a lesson in honesty: the Infantil branch only has three members, so its "third heaviest reader" has zero loans. It's 11-04's warning about publishing rankings from tiny samples.

-- QR-14 · Librarian org chart, with each person's level and hierarchical path
WITH RECURSIVE tree AS (
    SELECT lb.id, lb.name || ' ' || lb.last_name AS librarian, lb.job_title,
           1 AS level, lb.last_name::text AS path
    FROM   librarians AS lb WHERE lb.supervisor_id IS NULL      -- base case: the director
    UNION ALL
    SELECT ch.id, ch.name || ' ' || ch.last_name, ch.job_title, t.level + 1, t.path || ' > ' || ch.last_name
    FROM   librarians AS ch JOIN tree AS t ON t.id = ch.supervisor_id)   -- recursive step
SELECT level, repeat('    ', level - 1) || librarian AS org_chart, job_title, path
FROM   tree ORDER BY path;   -- ORDER BY path = each person below their supervisor
level org_chart job_title path
1 Helena Corvo Network director Corvo
3 ········Fátima Cordero Loans assistant Corvo > Nogueira > Cordero

(2 of 8 rows: 1 at level 1, 3 at level 2 and 4 at level 3.) The path does two jobs at once, and that's the trick worth knowing: it can be read at a glance and, above all, it's what makes ORDER BY path put each person below their supervisor. Ordering by level would give you all the supervisors together and then all the assistants, which isn't an org chart. The base case is supervisor_id IS NULL, and that's why the data set needs a librarian with no supervisor: without one the recursion doesn't start and the query returns zero rows. With real data you should also accumulate the visited ids and cut the cycles, because a looping supervisor_id would spin the query forever (10-02).

-- QR-15 · Pivoted report: loans by branch and subject, without losing a single branch
SELECT b.name AS branch,
       COUNT(l.id) FILTER (WHERE s.name = 'Fiction')   AS fiction,
       COUNT(l.id) FILTER (WHERE s.name = 'Poetry')    AS poetry,
       COUNT(l.id) FILTER (WHERE s.name = 'History')   AS history,
       COUNT(l.id) FILTER (WHERE s.name = 'Science')   AS science,
       COUNT(l.id) FILTER (WHERE s.name = 'Children')  AS children,
       COUNT(l.id) FILTER (WHERE s.name = 'Computing') AS computing, COUNT(l.id) AS total
FROM   branches AS b
LEFT   JOIN copies AS c ON c.branch_id = b.id      LEFT JOIN works AS w ON w.id = c.work_id
LEFT   JOIN subjects AS s ON s.id = w.subject_id   LEFT JOIN loans AS l ON l.copy_id = c.id
GROUP  BY b.id, b.name ORDER BY total DESC;
branch fiction poetry history science children computing total
Biblioteca Central de Alvorada 8 2 2 2 2 4 20
Biblioteca de Vila Nova 4 0 2 2 0 2 10
Biblioteca Infantil do Parque 0 0 0 0 6 0 6

Validation by two routes: the rows add up to 20 + 10 + 6 = 36, the total number of loans, and the columns add up to 12 + 2 + 4 + 4 + 8 + 6 = 36 as well. If either of the two didn't work out, the query would be wrong even if the figures looked reasonable (11-04). The chain of four LEFT JOINs is deliberate: it only takes one of them being INNER for a branch with no copies to disappear. And the business reading is immediate: the Infantil do Parque lends only children's books, while the Central is the only one with holdings in all six subjects — an imbalance no overall total would show.

  1. The reference indexes

Seven indexes, each with the query that justifies it (PR-05). Not one more:

Index Query that uses it What changes in the plan
uq_active_loan_per_copy (unique, partial) QR-03, QR-06, the overdue view It's constraint IR-03 and the access path to the active loans: an Index Scan over a handful of entries instead of a Seq Scan over the whole history
idx_loans_member QR-09, QR-13, the member's record PostgreSQL does not index FKs (08-01): without it, a member's record reads the whole table. The same goes for idx_copies_work and idx_copies_branch, the two most frequent navigations in the system (QR-02, QR-06, QR-11, QR-15)
idx_loans_copy_date on (copy_id, loan_date), and idx_loans_date QR-11, QR-12, QR-15 and period reports The composite one filters by copy and throws in the date ordering with no sort (08-02); the second turns the scan of the history into a range read
idx_works_title_lower on LOWER(title) Catalogue search An expression index: a WHERE LOWER(title) = ... can't use an index on title (08-03)

What is deliberately not indexed (PR-07): members.type and status (three and four values, the planner will prefer the scan), subjects, branches and publishers in their entirety (they fit in one page), and works_authors, whose composite PK already serves for going from the work to the author — although not the other way round: if "all the works by an author" were needed, you'd have to add (author_id, work_id). That nuance is the column ordering of a composite index that 08-02 explained. And on title search (PR-04): LOWER(title) resolves equality and prefixes (LIKE 'garden%'), but not a word in the middle; for that you need trigrams (pg_trgm with GIN) or full text (to_tsvector). The right answer in the report isn't "I use GIN", it's saying what kind of search is needed and choosing accordingly.

  1. The view, the procedure and the trigger

CREATE OR REPLACE VIEW v_overdue_loans AS
SELECT l.id AS loan_id, m.id AS member_id, m.name || ' ' || m.last_name AS member,
       m.email, w.title, c.barcode, b.name AS branch, l.due_date,
       CURRENT_DATE - l.due_date AS days_late,
       LEAST(0.20 * (CURRENT_DATE - l.due_date), 20.00) AS estimated_fine
FROM   loans AS l JOIN copies AS c ON c.id = l.copy_id
JOIN   works AS w ON w.id = c.work_id            JOIN branches AS b ON b.id = c.branch_id
JOIN   members AS m ON m.id = l.member_id
WHERE  l.return_date IS NULL AND l.due_date < CURRENT_DATE;

With the reference date it returns three rows: Lena Fuentes with 36 days and €7.20, and Nuno Barros and Alba Rey with 1 day and €0.20 each. Two decisions: it's called estimated_fine because the fine doesn't exist until the return (BR-10), and the LEAST applies the €20 cap inside the view, so nobody has to remember it.

CREATE OR REPLACE PROCEDURE register_return(p_loan_id INTEGER)
LANGUAGE plpgsql AS $$
DECLARE v_due DATE; v_work_id INTEGER; v_late INTEGER;
BEGIN   UPDATE loans SET return_date = CURRENT_DATE                     -- 1. close it
    WHERE  id = p_loan_id AND return_date IS NULL RETURNING due_date INTO v_due;
    IF NOT FOUND THEN RAISE EXCEPTION 'Loan % had already been returned', p_loan_id; END IF;
    v_late := CURRENT_DATE - v_due;                                 -- 2. the fine (BR-09)
    IF v_late > 0 THEN INSERT INTO fines (loan_id, amount, days_late, issued_date)
        VALUES (p_loan_id, LEAST(0.20 * v_late, 20.00), v_late, CURRENT_DATE);
    END IF;
    SELECT c.work_id INTO v_work_id                                 -- 3. notify (BR-08)
    FROM   loans AS l JOIN copies AS c ON c.id = l.copy_id WHERE l.id = p_loan_id;
    UPDATE reservations SET status = 'available', notified_date = CURRENT_DATE
    WHERE  id = (SELECT r.id FROM reservations AS r WHERE r.work_id = v_work_id
                 AND r.status = 'waiting' ORDER BY r.reservation_date, r.id LIMIT 1);
END; $$;

Four details worth copying: the UPDATE ... RETURNING reads and writes in a single step (05-02); the AND return_date IS NULL makes the operation idempotent, so calling it twice doesn't close it twice or issue two fines; the IF NOT FOUND turns a silent failure into an exception that rolls the whole transaction back; and the ORDER BY r.reservation_date, r.id respects the queue with a tie-breaker, just like QR-10. With several desks open at once, that SELECT ... LIMIT 1 would also want FOR UPDATE SKIP LOCKED (09-05), so two simultaneous returns of the same work don't notify the same member.

CREATE OR REPLACE FUNCTION fn_check_member() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE v_status TEXT;
BEGIN   SELECT status INTO v_status FROM members WHERE id = NEW.member_id;
    IF v_status <> 'active' THEN
        RAISE EXCEPTION 'Member % has status "%": they cannot borrow (BR-11)',
                        NEW.member_id, v_status;
    END IF;   RETURN NEW;
END; $$;
CREATE TRIGGER trg_loan_member_active BEFORE INSERT ON loans
    FOR EACH ROW EXECUTE FUNCTION fn_check_member();

With Diego Andrade (member 6, suspended over his €13.40 unpaid), any INSERT into loans ends in ERROR: Member 6 has status "suspended": they cannot borrow (BR-11). It's the only trigger in the project, and its justification is 10-05's: the rule depends on another table, so a CHECK can't express it (05-01), and it must hold wherever the INSERT comes from.

  1. Frequent mistakes in this project

Mistake How it shows up How it's fixed
Confusing work and copy, or not being able to express "active loan" A books table with num_copies, or loans.work_id; an active BOOLEAN that ends up contradicting return_date Redo the model —it's the failure that wipes out the whole rubric block—; and remember that return_date IS NULL is the state, protected by the partial index
Subtracting dates wrongly, or adding closed and potential debt together Negative delays, fines computed from loan_date, or an outstanding figure larger than the sum of fines GREATEST(diff, 0), LEAST(amount, cap), the delay always measured against the due date, and two separate metrics (BR-10)
Deleting instead of closing the record, or modelling the queue with a boolean A CASCADE that takes the history with it; an is_next that two processes set to TRUE at the same time status = 'closed' with ON DELETE RESTRICT (DR-10), and reservation_date + ROW_NUMBER() (QR-10)
COUNT(*) after a LEFT JOIN, or filtering the right-hand table in the WHERE Members with no loans showing 1; or the LEFT JOIN turned into a JOIN and the zeros gone COUNT(right_hand_column), and the right-hand table's condition in the ON

Common Mistakes and Tips

  • Reading these solutions instead of comparing against them. Open your file alongside and go query by query: where you agree, confirm that you know why; where you differ, decide which is better and note it in the report. That's the part that gets marked.
  • Copying a solution you don't understand. In the defence (12-05) they'll ask you why LATERAL and not a CTE, or why ROW_NUMBER and not RANK; if you don't know, it shows in ten seconds. And taking a query as good because it returns rows. Returning rows isn't returning the right ones. Validate by two routes: QR-15 adds up to 36 by rows and by columns, QR-12 closes the running total at 36 and QR-08 squares with the 6 fines.
  • Tip: save the output of the fifteen queries to a file. When you touch the schema or the data, a diff will tell you instantly what has moved and whether it was what you expected (11-04). And write next to each query the lesson it applies: it turns the project into an index of what you know how to do.

Exercises

Exercise 1

On QR-08. (1) Write the query for each member's real total debt: unpaid fines plus the estimated fine on their overdue loans not yet returned. (2) How much does Diego Andrade owe under that definition and how much under QR-08's? (3) Which one would you publish to the treasury and which to management?

Exercise 2

QR-11 uses LATERAL. (1) Rewrite it with a CTE and ROW_NUMBER(). (2) Give one reason to prefer each version. (3) What happens to each one if a branch has no loans at all?

Exercise 3

A colleague hands this in as "most-borrowed works": SELECT w.title, COUNT(*) FROM works w JOIN copies c ON c.work_id = w.id JOIN loans l ON l.copy_id = c.id GROUP BY w.title ORDER BY 2 DESC LIMIT 5; (1) What three problems does it have? (2) Fix it. (3) Which of the three shows up today with this data?

Solutions

Solution 1 — Two correlated subqueries, one per metric, and never added together in the same column:

SELECT m.id, m.name || ' ' || m.last_name AS member, m.status,
       COALESCE((SELECT SUM(f.amount) FROM fines AS f JOIN loans AS l2 ON l2.id = f.loan_id
                 WHERE l2.member_id = m.id AND f.paid_date IS NULL), 0) AS issued_debt,
       COALESCE((SELECT SUM(LEAST(0.20 * (DATE '2026-06-30' - l3.due_date), 20.00))
                 FROM loans AS l3 WHERE l3.member_id = m.id
                 AND l3.return_date IS NULL
                 AND l3.due_date < DATE '2026-06-30'), 0)               AS potential_debt
FROM members AS m ORDER BY issued_debt + potential_debt DESC, m.id;

(2) Diego Andrade owes €13.40 under both definitions, because he has no overdue loans still out. The ones who change are others: Lena Fuentes goes from €0.00 to €7.20, and Nuno Barros and Alba Rey from €0.00 to €0.20; the total potential debt is €7.60. (3) To the treasury, QR-08's: those are the only euros collectable today, with an issued fine behind them. To management, both columns together, because the potential one anticipates what will come in and points at who to call before the debt grows. What you can never do is add them up in a column called "debt": that's the mistake in section 5.

Solution 2 — The CTE is QR-13's applied to works: ROW_NUMBER() OVER (PARTITION BY b.id ORDER BY COUNT(*) DESC, w.title) over a GROUP BY b.id, w.id, and a WHERE rank_ <= 3 outside. (2) In favour of the CTE: it's standard, portable SQL —LATERAL doesn't exist in MySQL before 8.0.14 or in SQLite— and it reads from top to bottom. In favour of LATERAL: it only brings back 3 rows per branch instead of computing the ranking of every work only to discard almost all of them, which with a hundred thousand titles is the difference between milliseconds and seconds. (3) The CTE loses the branch with no loans, because there'd be nothing to group; the LEFT JOIN LATERAL ... ON TRUE version keeps it with NULL in the subquery's columns. To make them equivalent you'd have to start from branches with a LEFT JOIN against the count.

Solution 3 — (1) It groups by title instead of by id: two different works with the same title —two editions of a classic, a common thing in a library— would be merged into one row with the sum of both. ORDER BY 2 DESC with no tie-breaker: with LIMIT 5 and several works tied, which five come out depends on the plan and the report stops being reproducible. And COUNT(*) with no alias: the column will be called count and whoever reads the result won't know whether it counts loans, copies or rows of the JOIN (11-02). The definition is missing too: it counts all loans, including the open ones and those of members whose accounts were closed. (2) The correct version:

SELECT w.id, w.title, COUNT(l.id) AS loans
FROM   works AS w JOIN copies AS c ON c.work_id = w.id JOIN loans AS l ON l.copy_id = c.id
GROUP  BY w.id, w.title ORDER BY loans DESC, w.title LIMIT 5;

(3) The tie-breaker one. With this data there are no two works with the same title, so the first problem doesn't show up — and that's the danger: the query passes the tests and fails the day somebody catalogues a second edition. On the other hand A Brief History of Alvorada, Letters from the Lighthouse, The Atom and the Doubt and The Girl Who Counted Stars all four have 4 loans, so fifth place in the LIMIT 5 is currently a lottery between four candidates.

Conclusion

You now have something to compare yourself against:

  • The reference schema and its decisions, each with its valid alternative: the composite PK of works_authors versus the surrogate id, the stored due_date, derived versus stored state, fines as a table, the computed queue, and above all the partial unique index versus the EXCLUDE constraint — the first if only the present matters, the second if historical overlaps have to be prevented as well.
  • The 15 solved queries, each with its key decision: the ORDER BY tie-breaker (QR-01, QR-13), the two copies of the same title that aren't a duplicate (QR-02), the GREATEST(..., 0) for the delay (QR-03), the anti-join that isn't NOT IN (QR-04), the HAVING that isn't a WHERE (QR-05), the COUNT(c.id) that isn't COUNT(*) (QR-06, QR-09), the ORDER BY inside the string_agg (QR-07), the COALESCE that turns the NULL into 0.00 (QR-08), the position computed with ROW_NUMBER (QR-10), the LATERAL that sees the outer row (QR-11), the calendar that makes the empty months visible (QR-12), the path that orders the org chart (QR-14) and the pivot that squares by rows and by columns (QR-15).
  • Seven indexes with the query that justifies each one and the list of what is deliberately not indexed; plus one view, one procedure and one trigger, and not one more: the view that defines "overdue" and calls estimated_fine what isn't a fine yet, the idempotent and atomic procedure, and the only trigger that expresses a rule no CHECK can. And the frequent mistakes in the project, led by the one that wipes it out —confusing work with copy—, with the reminder that a different solution can be just as valid if it meets the requirements and is justified.

The last part is still missing, and it's the one that decides how everything above is judged. In the next lesson, Presenting the Project, you'll see how a piece of technical work is communicated: the structure of the report section by section, how data results are presented without misleading in good faith, the questions you'll be asked in the defence and how to prepare them, how the project is published in a repository somebody can run in five minutes, the self-assessment with the rubric turned into a checklist — and the close of the whole course.

SQL Course

Module 1: Introduction to SQL

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved