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
- Step 1 — From the brief to the model
- Step 2 — The DDL:
01-schema.sql - Step 3 — Coherent test data:
02-data.sql - Step 4 — The queries: a working method
- Step 5 — The indexes, after the queries
- Step 6 — Views, procedures and triggers
- Step 7 — Security and delivery
- Indicative schedule
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- Step 2 — The DDL:
01-schema.sql
01-schema.sqlThe 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 1Three 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.
- Step 3 — Coherent test data:
02-data.sql
02-data.sqlTest 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 |
- Step 4 — The queries: a working method
- Start with the
FROM, not theSELECT. Decide first which table one row of the result comes from: one per work? ThenFROMisworks. One per loan? Then it'sloans. Everything else joins onto it. - Check the row count after every
JOIN. If joiningcopieswithloanstakes you from 20 rows to 36, you've changed granularity: it may be correct, but you have to know it, because from there onCOUNT(*)counts loans, not copies. - Decide
JOINorLEFT JOINby asking about the zeros. Do I want to see the work with no copies, the branch with no loans, the empty month? ThenLEFT JOIN— and remember thatCOUNT(*)counts the phantom row andCOUNT(right_hand_column)doesn't (04-04). And validate the total by two routes: QR-15's pivot must add up to the same asSELECT 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.
- 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:
- List the
WHERE, theJOINand theORDER BYof every query. That list is your candidate list, and only that (11-05). - 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.
- Discard what adds nothing —a three-value column like
members.typeor a six-row table likesubjectsdon't get indexed: the planner will ignore them, and rightly so— and measure withEXPLAIN(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.
- 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.
- 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.
- 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 JOINcondition in theWHERE. The most expensive and most silent mistake in the project: it turns theLEFT JOINinto aJOIN, the zeros disappear and the result still looks reasonable. A condition on the right-hand table ⇒ it goes in theON. COUNT(*)after aLEFT JOIN. It counts the phantom row and members with no loans come out with 1. UseCOUNT(right_hand_column).- Adding the value
on_loantocopies.status, or generating random data without looking at the constraints. The first duplicates information that will end up contradictingloans; 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-nnnext to each constraint. AnINSERTthat 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.sqlwith the counts, the eight gaps, theINSERTs 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:
copiesis a table because the object has its own branch, status and history;due_dateis stored because it's a historical fact and it also moves with renewals, just likeunit_price; fines are a table because they have a life cycle; the queue is computed fromreservation_dateinstead 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 indexWHERE 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_seriesfor 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
FROMby deciding what one row of the result is, check the row count after everyJOIN, chooseLEFT JOINby asking about the zeros and validate the total by two routes. The two worked examples teach the two traps: theLEFT JOINcondition goes in theON, andCOUNT(*)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 withhas_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
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
