You have the brief (12-01) and the contract (12-02). What's left is to build it, and building it has an order: if you write the queries before you have data, you can't check them; if you create the indexes before you have the queries, you're inventing them; and if you start with CREATE TABLE without having drawn anything, the third table will force you to redo the first two.

This lesson is the build guide in seven steps: the complete model, the whole DDL, the technique for generating coherent data, the method for writing queries without going wrong and the criteria for deciding indexes and encapsulation. What it doesn't give you is the fifteen queries from the brief, already written: that's 12-04, and getting there without having tried is throwing the project away.

Contents

  1. Step 1 — From the brief to the model
  2. Step 2 — The DDL: 01-schema.sql
  3. Step 3 — Coherent test data: 02-data.sql
  4. Step 4 — The queries: a working method
  5. Step 5 — The indexes, after the queries
  6. Step 6 — Views, procedures and triggers
  7. Step 7 — Security and delivery
  8. Indicative schedule
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. Step 1 — From the brief to the model

erDiagram
    BRANCHES       ||--o{ COPIES        : "holds"
    BRANCHES       ||--o{ LIBRARIANS    : "employs"
    BRANCHES       ||--o{ MEMBERS       : "registers"
    BRANCHES       ||--o{ RESERVATIONS  : "is pickup point for"
    LIBRARIANS     ||--o{ LIBRARIANS    : "supervises"
    LIBRARIANS     ||--o{ LOANS         : "processes"
    SUBJECTS       ||--o{ WORKS         : "classifies"
    PUBLISHERS     ||--o{ WORKS         : "publishes"
    WORKS          ||--o{ WORKS_AUTHORS : "is credited in"
    AUTHORS        ||--o{ WORKS_AUTHORS : "signs"
    WORKS          ||--o{ COPIES        : "materialises as"
    WORKS          ||--o{ RESERVATIONS  : "is reserved as"
    COPIES         ||--o{ LOANS         : "is lent as"
    MEMBERS        ||--o{ LOANS         : "takes out"
    MEMBERS        ||--o{ RESERVATIONS  : "requests"
    LOANS          ||--o| FINES         : "generates"

Twelve tables and sixteen relationships: fourteen 1:N, one N:M resolved with the bridge table works_authors, one self-referencing on librarians and one 1-to-0..1 between loans and fines. The shape is GreenStore's; the content isn't. The columns are in step 2's DDL.

The five hard decisions

1. Why copies is a table and not a counter. Argued in 12-01: the copy has its own branch, its own status and its own history. The technical consequence is more forceful than the conceptual argument: without that table, the FK of loans would point at works and it would be impossible to know which of the three came back. It isn't a matter of style: the model simply can't represent the fact.

2. Why loans stores due_date instead of computing it. It could be deduced (loan_date + term(member type)), and it isn't: the argument is identical to the one for order_lines.unit_price (01-06, 11-02), it's a historical fact. If Marta moves from general to senior, her old loans would be recomputed at 30 days and one that came back late would become on time. Besides, it moves with renewals (BR-03), so it isn't even a function of the initial date. Rule: if the value depends on a past condition that can change, store it.

3. Why fines are a table and not a column. They have a life of their own —they're issued, they're paid, they can be waived—, and that's three more columns, null for 83 % of loans; a table lets you count and sum without walking through every loan; and tomorrow there could be fines that don't come from a delay (a damaged book), and it would be enough to make the FK nullable. A 1-to-0..1 relationship, enforced with UNIQUE (loan_id).

4. How the reservation queue is modelled. With reservation_date and nothing else. The three options:

Option Problem
A position INTEGER column Cancelling reservation 2 forces you to renumber the ones after it: concurrency, gaps and mistakes
An is_next BOOLEAN column The classic antipattern: a boolean that can only be true on one row, with nothing to guarantee it
reservation_date + ROW_NUMBER() The position is computed at query time. Cancelling means changing a status; the queue reorders itself

5. Why the loan's state isn't stored and the copy's is. They look symmetrical and they aren't. The loan's (active, overdue, returned) is a function of two dates and the clock: storing it would force a nightly job to mark the overdue ones. The copy's (available, repair, lost, withdrawn) is a physical fact that somebody decides and that isn't deduced from anything. That's why copies.status doesn't include on_loan: that one is deduced, and having it would mean storing the same thing twice with two ways of contradicting itself.

  1. Step 2 — The DDL: 01-schema.sql

The order is 05-01's: DROP from children to parents, CREATE from parents to children.

Eight of the twelve tables hold no surprises: they're the exact pattern of categories, suppliers and returns in GreenStore, so they're summarised in this table —columns, nullability and named constraints— and writing their CREATE TABLE will take you five minutes:

Table Columns Constraints
branches name, address, phone (nullable: it may not have its own line), opening_date pk_branches, uq_branches_name
subjects name, udc (nullable: not all of them are classified) pk_subjects, uq_subjects_name
publishers name, country pk_publishers, uq_publishers_name
authors name, last_name, nationality and birth_year (both nullable: they may be unknown) pk_authors, chk_authors_year
works title, subject_id, publisher_id (nullable: self-published), publication_year, isbn (nullable: works predating the ISBN), language pk_works, uq_works_isbn (IR-08), fk_works_subject with RESTRICT, fk_works_publisher with SET NULL, chk_works_year
fines loan_id, amount NUMERIC(10,2), days_late, issued_date, paid_date (nullable: NULL = unpaid) pk_fines, uq_fines_loan (IR-07, the one that enforces the 1-to-0..1), fk_fines_loan with RESTRICT, chk_fines_amount, chk_fines_days, chk_fines_paid
members name, last_name, id_card, email (nullable: child members don't have one), birth_date, type, status, branch_id, signup_date pk_members, uq_members_id_card, uq_members_email (which allows several NULLs, 05-01), fk_members_branch, and IR-10's two closed domains: chk_members_type IN ('child','general','senior') and chk_members_status IN ('active','suspended','closed')
reservations work_id (the work, not the copy), member_id, pickup branch_id, reservation_date (the queue position comes from this), status, notified_date and closed_date (nullable) pk_reservations, fk_reservations_work with CASCADE and the other two with RESTRICT, chk_reservations_status IN ('waiting','available','completed','cancelled','expired'), chk_reservations_notified

And these are the four that do have decisions inside the CREATE TABLE itself:

-- =====================================================================
-- Alvorada Public Library - 01-schema.sql - PostgreSQL 16
-- =====================================================================
-- Dropped in reverse dependency order, so the script is idempotent
DROP TABLE IF EXISTS fines CASCADE;  DROP TABLE IF EXISTS reservations CASCADE;
DROP TABLE IF EXISTS loans CASCADE;  DROP TABLE IF EXISTS copies CASCADE;
DROP TABLE IF EXISTS works_authors CASCADE;  DROP TABLE IF EXISTS works CASCADE;
DROP TABLE IF EXISTS members CASCADE;  DROP TABLE IF EXISTS librarians CASCADE;
DROP TABLE IF EXISTS authors CASCADE;  DROP TABLE IF EXISTS publishers CASCADE;
DROP TABLE IF EXISTS subjects CASCADE;  DROP TABLE IF EXISTS branches CASCADE;
-- ... and here go, in dependency order, the seven tables from the table above ...
CREATE TABLE librarians (
    id            INTEGER      GENERATED BY DEFAULT AS IDENTITY,
    name          VARCHAR(60)  NOT NULL,
    last_name     VARCHAR(90)  NOT NULL,
    job_title     VARCHAR(80)  NOT NULL,
    branch_id     INTEGER      NOT NULL,
    supervisor_id INTEGER,                        -- NULL: only the network director
    email         VARCHAR(120) NOT NULL,
    hire_date     DATE         NOT NULL,
    CONSTRAINT pk_librarians PRIMARY KEY (id),
    CONSTRAINT uq_librarians_email UNIQUE (email),
    CONSTRAINT fk_lib_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE RESTRICT,
    CONSTRAINT fk_lib_supervisor FOREIGN KEY (supervisor_id)              -- self-referencing
               REFERENCES librarians(id) ON DELETE SET NULL,
    CONSTRAINT chk_lib_not_self_boss CHECK (supervisor_id <> id)              -- IR-12
);

-- ---------------------------------------- N:M bridge and physical copies
CREATE TABLE works_authors (
    work_id      INTEGER     NOT NULL,
    author_id    INTEGER     NOT NULL,
    role         VARCHAR(20) NOT NULL DEFAULT 'author',
    credit_order SMALLINT    NOT NULL DEFAULT 1,  -- credit order on the cover
    CONSTRAINT pk_works_authors PRIMARY KEY (work_id, author_id),      -- composite PK
    CONSTRAINT fk_wa_work   FOREIGN KEY (work_id)   REFERENCES works(id)   ON DELETE CASCADE,
    CONSTRAINT fk_wa_author FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE RESTRICT,
    CONSTRAINT chk_wa_role  CHECK (role IN ('author','coauthor','translator','illustrator')),
    CONSTRAINT chk_wa_order CHECK (credit_order > 0)
);
CREATE TABLE copies (
    id            INTEGER     GENERATED BY DEFAULT AS IDENTITY,
    work_id       INTEGER     NOT NULL,
    branch_id     INTEGER     NOT NULL,
    barcode       VARCHAR(20) NOT NULL,
    acquired_date DATE        NOT NULL,
    status        VARCHAR(15) NOT NULL DEFAULT 'available',
    CONSTRAINT pk_copies PRIMARY KEY (id),
    CONSTRAINT uq_copies_barcode UNIQUE (barcode),                         -- IR-09
    CONSTRAINT fk_copies_work FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE RESTRICT,
    CONSTRAINT fk_copies_branch FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE RESTRICT,
    -- Careful: there is NO 'on_loan' value. That one is deduced from loans
    CONSTRAINT chk_copies_status CHECK (status IN ('available','repair',
                                                   'lost','withdrawn'))
);

-- ------------------------------------------------- The operation: the loan
CREATE TABLE loans (
    id           INTEGER  GENERATED BY DEFAULT AS IDENTITY,
    copy_id      INTEGER  NOT NULL,
    member_id    INTEGER  NOT NULL,
    librarian_id INTEGER,                         -- NULL: self-service checkout
    loan_date    DATE     NOT NULL,
    due_date     DATE     NOT NULL,               -- stored: a historical fact
    return_date  DATE,                            -- NULL = ACTIVE loan
    renewals     SMALLINT NOT NULL DEFAULT 0,
    CONSTRAINT pk_loans PRIMARY KEY (id),                                     -- IR-14: the
    CONSTRAINT fk_loan_copy FOREIGN KEY (copy_id)                             -- history is
               REFERENCES copies(id) ON DELETE RESTRICT,                      -- never deleted
    CONSTRAINT fk_loan_member FOREIGN KEY (member_id) REFERENCES members(id) ON DELETE RESTRICT,
    CONSTRAINT fk_loan_librarian FOREIGN KEY (librarian_id)
               REFERENCES librarians(id) ON DELETE SET NULL,
    CONSTRAINT chk_loan_due    CHECK (due_date > loan_date),                     -- IR-01
    CONSTRAINT chk_loan_return CHECK (return_date IS NULL
                                      OR return_date >= loan_date),
    CONSTRAINT chk_loan_renewals CHECK (renewals BETWEEN 0 AND 2)                -- IR-04
);

-- IR-03: among loans NOT yet returned, a copy can appear only once
CREATE UNIQUE INDEX uq_active_loan_per_copy
    ON loans (copy_id)
    WHERE return_date IS NULL;

-- IR-13: a member can't have two LIVE reservations for the same work
CREATE UNIQUE INDEX uq_active_reservation_member_work
    ON reservations (work_id, member_id)
    WHERE status IN ('waiting','available');

The partial index, in depth

It's the technical centrepiece of the project. A partial index (08-02) is built only over the rows that meet a condition; when it's also UNIQUE, uniqueness is only required among those rows. It reads literally: "among loans not yet returned, copy_id is unique". Its two tests:

-- ⚠️ INCORRECT: copy 1 is already out with Sofía and hasn't been returned
INSERT INTO loans (copy_id, member_id, librarian_id, loan_date, due_date)
VALUES (1, 13, 5, DATE '2026-06-25', DATE '2026-07-16');
-- ERROR: duplicate key value violates unique constraint "uq_active_loan_per_copy"
-- DETAIL: Key (copy_id)=(1) already exists.

-- ✅ CORRECT: another loan of the same copy, already returned. History, not a live loan
INSERT INTO loans (copy_id, member_id, librarian_id,
                   loan_date, due_date, return_date)
VALUES (1, 13, 5, DATE '2024-01-10', DATE '2024-01-31', DATE '2024-01-28');
-- INSERT 0 1

Three properties make it the right solution and not a trick. It's declarative: the engine enforces it, not the application, so it survives scripts, another application and a console left open at three in the morning. It's tiny: with 300,000 historical loans and 400 active ones, the index has 400 entries, not 300,000. And it speeds things up too, because any query with WHERE return_date IS NULL can use it: a constraint that throws in a useful index for free.

And its limit, which has to be stated in the report: it prevents two active loans, not two loans that overlap in the past. If somebody registers today a January loan, already returned, that overlaps with another January one, the index doesn't see it. The complete solution is in 12-04.

  1. Step 3 — Coherent test data: 02-data.sql

Test data has two goals that get in each other's way: being enough for the queries to mean something and small enough to read with your own eyes. The split: by hand, with explicit ids, everything that appears in the queries —that way "loan 29" always means the same thing—; with generate_series, the volume for measuring performance.

-- Synthetic volume so indexes can be measured: ~50,000 historical loans
INSERT INTO loans (copy_id, member_id, librarian_id,
                   loan_date, due_date, return_date)
SELECT 1 + (random() * 19)::int, 1 + (random() * 14)::int, 1 + (random() * 7)::int,
       d, d + 21, d + 21 - (random() * 10)::int    -- ALL returned: they don't break IR-03
FROM   generate_series(DATE '2020-01-01', DATE '2025-08-31', INTERVAL '1 hour') AS g(d);

The detail that decides whether this works: every generated row carries a return_date. If you left nulls at random, the partial unique index would abort the load as soon as two loans of the same copy were left open. Far from being a nuisance, it's proof that the constraint works.

The edge cases the data must contain

GreenStore had deliberate gaps —customers with no orders, products never sold, orders with no employee— and the lessons needed them. Now it's your turn to put them in: without them, half a badly written query returns the same as a well written one and you never find out.

Edge case Why it's essential In the data set
Members with no loans at all Anti-join (QR-04) and the LEFT JOIN of rankings (QR-13) 3
A work with no copies and copies never lent The availability LEFT JOIN (QR-06) and the anti-join (QR-04) 1 and 3
Loans returned late and unpaid fines Late rate, outstanding debt and suspension (QR-08, BR-11) 6 (from 7 to 46 days) and 2, adding up to €13.40
Active overdue loans The overdue view and potential debt (QR-03) 3
All the copies of one work on loan, and reservations in three statuses The reservation queue (QR-10): without the first, the queue can't exist The Garden of Hours: all 3 of them; and 3 reservations waiting, 1 completed, 1 expired
A librarian with no supervisor and a loan with no librarian The recursive CTE (QR-14, it's the base case) and a NULL in an FK with a LEFT JOIN (04-03) 1 and 1 (self-service checkout)
A copy in repair Telling enabled apart from available (QR-06) 1
A suspended member and a closed member Status filters; history that survives account closure 1 and 1
Renewed loans BR-03: due_date ≠ loan_date + term 2
Months with no loans at all The gapless time series (QR-12) 2

Verifying the load

As in 01-06, check before you write a single query. The reference data set has 3 branches, 6 subjects, 5 publishers, 10 authors, 8 librarians, 12 works, 15 rows of works_authors, 20 copies, 15 members, 36 loans, 5 reservations and 6 fines. And the gaps, in a single query:

SELECT (SELECT COUNT(*) FROM members AS m WHERE NOT EXISTS
          (SELECT 1 FROM loans AS l WHERE l.member_id = m.id))         AS members_without_loans,
       (SELECT COUNT(*) FROM works AS w WHERE NOT EXISTS
          (SELECT 1 FROM copies AS c WHERE c.work_id = w.id))          AS works_without_copies,
       (SELECT COUNT(*) FROM copies AS c WHERE NOT EXISTS
          (SELECT 1 FROM loans AS l WHERE l.copy_id = c.id))           AS copies_never_lent,
       (SELECT COUNT(*) FROM loans WHERE return_date IS NULL)          AS active,
       (SELECT COUNT(*) FROM loans WHERE return_date IS NULL
                       AND due_date < DATE '2026-06-30')               AS overdue,
       (SELECT COUNT(*) FROM loans WHERE librarian_id IS NULL)         AS without_librarian,
       (SELECT COUNT(*) FROM librarians WHERE supervisor_id IS NULL)   AS without_supervisor,
       (SELECT COUNT(*) FROM fines WHERE paid_date IS NULL)            AS unpaid_fines;
members_without_loans works_without_copies copies_never_lent active overdue without_librarian without_supervisor unpaid_fines
3 1 3 6 3 1 1 2

  1. Step 4 — The queries: a working method

  1. Start with the FROM, not the SELECT. Decide first which table one row of the result comes from: one per work? Then FROM is works. One per loan? Then it's loans. Everything else joins onto it.
  2. Check the row count after every JOIN. If joining copies with loans takes you from 20 rows to 36, you've changed granularity: it may be correct, but you have to know it, because from there on COUNT(*) counts loans, not copies.
  3. Decide JOIN or LEFT JOIN by asking about the zeros. Do I want to see the work with no copies, the branch with no loans, the empty month? Then LEFT JOIN — and remember that COUNT(*) counts the phantom row and COUNT(right_hand_column) doesn't (04-04). And validate the total by two routes: QR-15's pivot must add up to the same as SELECT COUNT(*) FROM loans; if it doesn't match, don't argue with the query, it's wrong (11-04).

Worked example A — the map of a work

"Tell me where the three copies of The Garden of Hours are and who has them." It's the question Helena's email opened with, and it sums up the entire project.

SELECT c.barcode, b.name AS branch, c.status,
       COALESCE(m.name || ' ' || m.last_name, '-- on the shelf --') AS held_by,
       l.due_date                                                   AS due_back
FROM   copies AS c
JOIN   branches AS b ON b.id = c.branch_id
LEFT   JOIN loans   AS l ON l.copy_id = c.id AND l.return_date IS NULL
LEFT   JOIN members AS m ON m.id = l.member_id
WHERE  c.work_id = 1
ORDER  BY c.barcode;
barcode branch status held_by due_back
ALV-0001 Biblioteca Central de Alvorada available Sofía Terán 2026-07-06
ALV-0002 Biblioteca Central de Alvorada available Rosa Pimentel 2026-07-01
ALV-0003 Biblioteca de Vila Nova available Lena Fuentes 2026-05-25

Three decisions to justify. FROM copies, because I want one row per copy, whether it's out or not. The condition l.return_date IS NULL goes in the ON, not the WHERE: there, copies with no active loan would disappear and the LEFT JOIN would be a JOIN in disguise (03-03). And status still says available on all three, and that's right: it's the physical condition of the volume; the fact that it's out shows up in held_by. It's step 1's separation, turned into a column.

Worked example B — members who don't come in

Helena asked for "the members who haven't been in for a long time". Before writing anything you have to define it (11-04): here, a member with status active whose last loan is older than 90 days or who has never taken one out. Closed accounts are deliberately left out.

SELECT m.id, m.name || ' ' || m.last_name AS member, m.type,
       MAX(l.loan_date)                   AS last_loan,
       COUNT(l.id)                        AS total_loans
FROM   members AS m
LEFT   JOIN loans AS l ON l.member_id = m.id
WHERE  m.status = 'active'
GROUP  BY m.id, m.name, m.last_name, m.type
HAVING COALESCE(MAX(l.loan_date), DATE '1900-01-01')
       < DATE '2026-06-30' - INTERVAL '90 days'
ORDER  BY last_loan, m.id;
id member type last_loan total_loans
12 Irene Sampaio senior (null) 0
13 Hugo Marques general (null) 0
14 Carla Nieto child (null) 0
1 Marta Coelho general 2026-03-09 4

Four members and three lessons. The LEFT JOIN is mandatory: with an ordinary JOIN, the three who have never borrowed anything —the most inactive of all— would disappear. The COALESCE in the HAVING is what lets them through, because NULL < date gives UNKNOWN and HAVING discards anything that isn't true (04-03). And COUNT(l.id), not COUNT(*): with COUNT(*) the three of them would show 1 loan instead of 0. It's the single most repeated mistake in the whole project. And one reading that isn't about SQL: the three with no loans aren't the same problem —Carla signed up in February, Irene has had her card for a year without using it, and Marta has four loans and hasn't been in for three months—; putting them in the same figure is what 11-04 called mixing two questions into one number.

  1. Step 5 — The indexes, after the queries

The order is non-negotiable: the fifteen queries first, the indexes afterwards. An index exists to serve a specific query; if you can't name it, the index is surplus and only slows writes down (08-02). The method is mechanical:

  1. List the WHERE, the JOIN and the ORDER BY of every query. That list is your candidate list, and only that (11-05).
  2. Add the foreign keys you navigate: PostgreSQL indexes the PK, not the FK (08-01). It's the number one cause of sequential scans in a twelve-table schema.
  3. Discard what adds nothing —a three-value column like members.type or a six-row table like subjects don't get indexed: the planner will ignore them, and rightly so— and measure with EXPLAIN (08-05), not with intuition.
EXPLAIN (ANALYZE, BUFFERS) SELECT l.id, l.due_date FROM loans AS l
WHERE  l.member_id = 6 AND l.return_date IS NULL;

Without an index on loans(member_id) and with volume, the plan is a Seq Scan that reads the whole table; with it, an Index Scan that goes straight there. The important thing isn't that it improves, it's that you check it and write it down (PR-06). And with the 36 rows of the test set you'll see Seq Scan everywhere, because reading 36 rows is cheaper than opening an index — which is a lesson in itself: to talk about performance you need volume, and that's what step 3's generate_series is for. The reference indexes, with their plans, are in 12-04.

  1. Step 6 — Views, procedures and triggers

The rule that avoids disaster: encapsulate what repeats and what is delicate; don't encapsulate for the fun of it. Three pieces are enough and a fourth would already be suspicious. The code is in 12-04; what follows is the criterion, which is what you have to be able to decide.

Piece What it encapsulates Why that tool and not another
View v_overdue_loans The definition of "overdue" and the estimated fine calculation It's a query that's going to be run every day from three branches. It defines the metric once, which is the semantic layer of 10-01 and 11-04
Procedure register_return Closing the loan, issuing the fine if due and notifying the first reservation in the queue They're three writes that go together or not at all (module 9). A procedure puts them in one transaction and returns a clear error if anything fails (10-04)
Trigger trg_loan_member_active Preventing a loan to a suspended member (BR-11) It's a rule that depends on another table, and therefore beyond the reach of a CHECK (05-01). It must hold no matter what, wherever the INSERT comes from (10-05)

Two details of the view matter more than they look: its column is called estimated_fine, not fine, because it doesn't exist yet (BR-10) —calling it fine would be the first stone of a report that adds up money nobody owes—; and it uses CURRENT_DATE, so it changes by itself every night, which is exactly what was wanted and the reason for not storing "overdue" in a column.

And it stops here. The temptation to add a trigger for the maximum simultaneous loans, another to validate renewals and another to recompute suspension is enormous, and it's a mistake: triggers are invisible logic —whoever reads the INSERT doesn't see what happens— and debugging them is awkward. 10-05's criterion: a trigger for what must hold no matter what and can't be declared; a procedure for the business operation with several steps; the application for everything else.

  1. Step 7 — Security and delivery

The three roles from SR-01 to SR-03, following 11-03's pattern: privileges to the group, never to the user.

CREATE ROLE lib_read NOLOGIN; CREATE ROLE lib_desk NOLOGIN; CREATE ROLE lib_admin NOLOGIN;
GRANT USAGE ON SCHEMA public TO lib_read, lib_desk, lib_admin;

-- Read: catalogue only. No members, no loans, no fines
GRANT SELECT ON works, copies, authors, works_authors, subjects, publishers, branches
      TO lib_read;
-- Desk: operates, but does NOT delete (DR-10, SR-04). Admin: plus the catalogue
GRANT lib_read TO lib_desk;
GRANT SELECT, INSERT, UPDATE ON loans, reservations, fines TO lib_desk;
GRANT SELECT, UPDATE          ON members                   TO lib_desk;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO lib_desk;
GRANT lib_desk TO lib_admin;
GRANT INSERT, UPDATE ON works, copies, authors, works_authors, subjects, publishers
      TO lib_admin;
-- The real user is only a member of the group they belong to: GRANT lib_desk TO fatima;
-- And what's been granted is checked, instead of assumed
SELECT has_table_privilege('lib_read', 'members', 'SELECT') AS read_sees_members,
       has_table_privilege('lib_desk', 'loans',   'DELETE') AS desk_deletes;
read_sees_members desk_deletes
false false

Two falses, which is what was asked for. That two-line check is the proof of SR-01 and SR-04, and it goes in the report.

  1. Indicative schedule

In sessions of about two hours. Overrunning badly in session 3 is usually a sign that the model has a problem, not that you're slow:

Session Work Deliverable at the end
1 Glossary, business rules, model sketch on paper A diagram with the 12 boxes and their relationships
2 01-schema.sql: tables, named constraints, partial index The schema is created without errors twice in a row
3 02-data.sql with all the edge cases; verification queries The counts and the eight gaps add up
4 and 5 QR-01 to QR-08 one by one; then QR-09 to QR-15: correlated subqueries, LATERAL, windows, recursion and the pivot All fifteen, with the totals validated
6 and 7 Indexes and EXPLAIN before and after; view, procedure and trigger; roles; 04-report.md; a pass with 12-02's rubric The whole project, runnable from scratch

Common Mistakes and Tips

  • Creating the tables in whatever order occurs to you. Referential integrity imposes the order: parents before children when creating, the other way round when dropping (05-01). If your script only works the first time, it isn't idempotent and DL-02 isn't met.
  • Putting the LEFT JOIN condition in the WHERE. The most expensive and most silent mistake in the project: it turns the LEFT JOIN into a JOIN, the zeros disappear and the result still looks reasonable. A condition on the right-hand table ⇒ it goes in the ON.
  • COUNT(*) after a LEFT JOIN. It counts the phantom row and members with no loans come out with 1. Use COUNT(right_hand_column).
  • Adding the value on_loan to copies.status, or generating random data without looking at the constraints. The first duplicates information that will end up contradicting loans; the second (returns before the loan, two active loans of the same copy, fines with no delay) aborts the script halfway and leaves the database half loaded.
  • Tip: once the schema is created, try to violate it, and write the DDL with the -- IR-nn next to each constraint. An INSERT that should fail and does fail is worth more than three paragraphs of report, and going through the fourteen requirements with the rubric will take you a minute instead of half an hour.
  • Tip: keep a 99-checks.sql with the counts, the eight gaps, the INSERTs that must fail and the two privilege queries. It's your test suite: running it after each change will tell you instantly whether you've broken something.
  • Tip: write the report as you decide. Every hard decision, its paragraph, at the time. Reconstructing it on the last night is impossible and it shows.

Exercises

Exercise 1

Implement BR-04 in full: a loan can't be renewed if the maximum has been reached, if the loan is overdue or if the work has waiting reservations. (1) Which of the three conditions can be declared with a CHECK and which can't, and why? (2) Write an UPDATE that renews loan 32 only if all three allow it. (3) What does it return with the project's data?

Exercise 2

Loan 29 (Lena Fuentes, copy ALV-0003) is 36 days late and is returned today, 2026-06-30. (1) List all the rows that change, in which tables and with what values. (2) Why do the three operations have to go in the same transaction, and what would happen if the third failed after the first two? (3) What effect does the return have on the partial unique index?

Exercise 3

A colleague proposes adding a num_copies INTEGER column to works "so we don't have to count every time". (1) Give three arguments against. (2) Give a scenario where it would be defensible. (3) If it had to be done, how would you maintain it and what would it cost you?

Solutions

Solution 1 — (1) None of them, and for different reasons. The maximum number of renewals depends on the member's type, which is in another table, and a CHECK can't query other tables (05-01): the only declarable thing is the absolute range BETWEEN 0 AND 2. Whether the loan is overdue depends on CURRENT_DATE, and a CHECK with a non-deterministic function is discouraged —a row that's valid today would stop being valid tomorrow and a backup restore would fail for no reason—. Reservations are in another table. All three are operational logic. (2) Everything in the WHERE, which is where it's checked without reading first:

UPDATE loans AS l
SET    due_date = l.due_date
                + CASE m.type WHEN 'child'   THEN 14
                              WHEN 'general' THEN 21 ELSE 30 END,
       renewals = l.renewals + 1
FROM   members AS m
WHERE  m.id = l.member_id AND l.id = 32 AND l.return_date IS NULL
  AND  l.due_date >= DATE '2026-06-30'                            -- not overdue
  AND  l.renewals < CASE m.type WHEN 'child' THEN 1 ELSE 2 END    -- maximum
  AND  NOT EXISTS (SELECT 1                                       -- no queue
                   FROM   reservations AS r
                   JOIN   copies       AS c ON c.work_id = r.work_id
                   WHERE  c.id = l.copy_id
                     AND  r.status = 'waiting');

(3) It returns UPDATE 0. Loan 32 is Sofía Terán's with copy ALV-0001, of The Garden of Hours — the work that has three members in the queue. The third condition blocks it, and rightly so: renewing it would leave Nuno Barros waiting another month for a book whose three copies are all out. And UPDATE 0 isn't an error: you have to check for it in the application and turn it into a message.

Solution 2 — (1) Three rows in three tables change:

Table Operation Values
loans UPDATE of row 29 return_date goes from NULL to 2026-06-30
fines INSERT of a new row loan_id 29, days_late 36, amount €7.20 (0.20 × 36, below the €20 cap), no paid_date
reservations UPDATE of the oldest reservation for work 1 Nuno Barros's, from 2026-06-10, goes from waiting to available, with notified_date = today

(2) Because the three are a single business operation: a returned copy that generates its fine and activates its reservation. If the third failed without a transaction, you'd be left with a closed loan with its correct fine and a queue nobody has been notified in: Nuno would still be waiting for a book that's already on the desk, and with no trace of the failure. It's ACID in its simplest form (09-01, 09-02): all three, or none. In PL/pgSQL the procedure block is already an implicit transaction, so an exception in step 3 undoes the first two. (3) By ceasing to be active, loan 29 drops out of the partial unique index —its row no longer satisfies WHERE return_date IS NULL— and copy ALV-0003 can be lent again. The constraint releases itself: that's the elegance of the partial index compared with an active column somebody would have to remember to change.

Solution 3 — (1) First, it's pure redundancy: the data is already in copies and a COUNT(*) gets it in microseconds with the copies(work_id) index. Second, it has to be maintained on every copy added, withdrawn or moved; the day somebody inserts from a script, the figure is wrong forever and nobody notices, because a plausible number doesn't jump out at you. Third, it doesn't answer the real question: nobody asks how many copies there are, they ask how many are available right now, which depends on loans and changes by the minute.

(2) It would be defensible with millions of works, on a catalogue screen that shows the count on every search result and is read thousands of times a second, if the aggregate had been measured and were the bottleneck. In other words: when there's an EXPLAIN that justifies it, not before (08-04). And even then, the first option would be a materialized view (10-01) refreshed every night, which isolates the redundancy in an object marked as derived instead of hiding it in a column that looks like a fact.

(3) With an AFTER INSERT OR UPDATE OR DELETE trigger on copies that adds or subtracts. The cost: every write to copies also writes to works, which creates contention on the work's row —two simultaneous additions of copies of the same title get serialised (09-05)— and adds invisible logic; plus a recompute-from-scratch query, to fix it when (not if) it drifts out of sync. All that, to save a COUNT.

Conclusion

You now know how to build it:

  • The model: twelve tables and sixteen relationships, with one N:M with a composite primary key and one self-referencing relationship. And the five hard decisions argued: copies is a table because the object has its own branch, status and history; due_date is stored because it's a historical fact and it also moves with renewals, just like unit_price; fines are a table because they have a life cycle; the queue is computed from reservation_date instead of storing a position; and the loan's state is derived while the copy's is stored, because one is a function of the clock and the other a physical fact.
  • The DDL, with named constraints annotated with their IR-nn, and the partial unique index WHERE return_date IS NULL: declarative, tiny and useful as an index too, with its limit acknowledged —it doesn't detect historical overlaps—. The data: by hand for whatever appears in the queries, generate_series for volume (with every row returned, or IR-03 aborts the load) and the edge cases without which a badly written query passes for a good one, with the counts and the eight gaps verified before the first query is written.
  • The method: start with the FROM by deciding what one row of the result is, check the row count after every JOIN, choose LEFT JOIN by asking about the zeros and validate the total by two routes. The two worked examples teach the two traps: the LEFT JOIN condition goes in the ON, and COUNT(*) counts the phantom row.
  • The indexes after the queries, not the other way round; three encapsulations and not one more —the overdue view with its honest estimated_fine, the atomic return procedure and the only trigger that's needed—; and three roles of least privilege verified with has_table_privilege.

You've built it; now it's time to compare it, which is where you really learn. In the next lesson, Annotated Project Solutions, you'll find the solution set: the schema decisions justified one by one with the alternatives that would also be correct —including the EXCLUDE constraint and the debate between derived and stored state—; the 15 solved queries with their result, their key decision and the typical mistake for each one; the reference indexes with the plan that takes advantage of them; the code for the view, the procedure and the trigger; and the catalogue of what goes wrong most often in this project in particular.

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