Everything we have built in this module —the schema, the data, the JOINs, the reports— rests on an assumption we have so far taken for granted: that when loans says member_id = 14, member 14 exists. If that assumption fails, the JOINs silently lose rows, the counts lie and the previous lesson's reports stop being reliable without anyone noticing.
Referential integrity is the guarantee that this does not happen. It is the third rule of the relational model, we stated it in lesson 02-01 and we have been using it implicitly ever since we wrote REFERENCES in the CREATE TABLE. Now it is time to master it: how it is declared, exactly what the manager checks, what should happen when you delete a row that others depend on, how to detect the damage that already exists and —a warning that can save you months of bewilderment— why SQLite protects absolutely nothing unless you ask it to.
With this lesson we close module 2. When you finish it, BiblioRed will not merely have correct data: it will have a schema that actively prevents the data from ceasing to be correct.
Contents
- Orphan rows: where they come from and what they break
- Declaring a foreign key
- What the manager checks and when
- The
ON DELETEandON UPDATEreferential actions - The five options, compared
- Choosing the right action in BiblioRed
- BiblioRed's final schema with its referential actions
- Composite foreign keys
- Deferrable constraints
- SQLite:
PRAGMA foreign_keys = ON - Detecting and cleaning up orphan rows
- Validate in the application or in the database?
- Common mistakes and tips
- Exercises
- Conclusion
- Orphan rows: where they come from and what they break
An orphan row is a row whose foreign key points at something that does not exist. In the BiblioRed spreadsheet we diagnosed in lesson 01-01 there were three ways of creating them, and all three happened daily:
- Typing a made-up member number. Nobody checked anything: if the librarian typed
77instead of17, the row was stored quite happily. - Deleting a row that others depended on. When a member let their card lapse, somebody removed their line from the "Members" tab; their fifteen historical loans stayed in the "Loans" tab, pointing into the void.
- Renumbering. When the members sheet was reordered, the numbers changed and every earlier reference started pointing at a different person. This is the worst of the three, because it leaves no trace: the row is not orphaned, it is wrongly adopted.
What exactly an orphan row breaks:
INNER JOINs remove it without warning. The loans-per-branch report from lesson 02-05 would simply return less than there is. And since there is no error, nobody investigates it.LEFT JOINs keep it withNULL, which produces reports with gaps that somebody will have to explain.- The totals do not agree with each other.
COUNT(*) FROM loansgives 4,312 and the sum of loans per member gives 4,298. And from that point on, trust in the database evaporates.
Referential integrity turns those three ways of creating orphans into immediate errors, at the exact moment they are attempted. That is its value: the problem shows up when it can still be fixed, not six months later.
- Declaring a foreign key
We already did it in lesson 02-02; now in detail.
Column form
CREATE TABLE members (
member_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name VARCHAR(60) NOT NULL,
branch_id INTEGER NOT NULL REFERENCES branches (branch_id)
);Compact and sufficient for simple cases. The manager generates an automatic name for the constraint, something like members_branch_id_fkey.
Table form, with a proper name
CREATE TABLE members (
member_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
first_name VARCHAR(60) NOT NULL,
branch_id INTEGER NOT NULL,
CONSTRAINT pk_members PRIMARY KEY (member_id),
CONSTRAINT fk_members_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
);This is the one we use in BiblioRed, for three reasons:
- It is mandatory for composite foreign keys (section 8).
- It lets you name it. When the error fires, the message will say
fk_members_branch, notmembers_branch_id_fkey. - It lets you drop and recreate it with
ALTER TABLE ... DROP CONSTRAINT fk_members_branch, which is exactly what we will do in section 7.
Adding it later
ALTER TABLE members
ADD CONSTRAINT fk_members_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id);When you run it, PostgreSQL validates every existing row. If any of them is orphaned, the whole operation fails:
ERROR: insert or update on table "members" violates foreign key constraint "fk_members_branch"
DETAIL: Key (branch_id)=(99) is not present in table "branches".That is exactly what you want: the constraint does not come into force until the data is clean. Section 11 teaches you how to clean it.
Requirements of the referenced column
The column a foreign key points at must have a primary key or a UNIQUE constraint. You cannot reference just any column:
And it makes sense: if the referenced column could repeat, "point at the row with last name Alsina" would be ambiguous. That is why copies.book_id could point either at books.book_id (primary key) or at books.isbn (the UNIQUE alternate key), even though the former is the sensible choice.
- What the manager checks and when
A foreign key imposes checks on both tables, not just on the child. This is the complete picture:
| Operation | Table | What the manager checks |
|---|---|---|
INSERT on the child |
loans |
That the member_id value exists in members (or is NULL) |
UPDATE of the foreign key on the child |
loans |
The same: the new value must exist |
DELETE on the parent |
members |
That no child rows are left pointing at the deleted row |
UPDATE of the primary key on the parent |
members |
The same: that no children are left pointing at the old value |
INSERT on the parent |
members |
Nothing: adding a member never breaks anything |
Let's check it on biblioredb. First, from the child side:
INSERT INTO loans (member_id, copy_id, loan_date, due_date)
VALUES (77, 1, '2026-08-02', '2026-08-23');ERROR: insert or update on table "loans" violates foreign key constraint
"fk_loans_member"
DETAIL: Key (member_id)=(77) is not present in table "members".Now from the parent side:
ERROR: update or delete on table "members" violates foreign key constraint
"fk_loans_member" on table "loans"
DETAIL: Key (member_id)=(14) is still referenced from table "loans".And the NULL case, which is allowed:
-- books.author_id is NOT "NOT NULL", so a book with no author is legal
INSERT INTO books (book_id, isbn, title, author_id, publisher, publication_year, language)
VALUES (340, NULL, 'Municipal Bylaws of 1912', NULL, 'Vallmar City Council', 1912, 'es');A null foreign key points at nothing, and that is valid. The referential integrity rule talks about non-null values. If you want the reference to be mandatory, you have to add NOT NULL: that is a separate design decision. In BiblioRed, loans.member_id is NOT NULL (there is no such thing as a loan without a member) while books.author_id is not (works with no cataloged author do exist).
We undo the test insertion:
- The
ON DELETE and ON UPDATE referential actions
ON DELETE and ON UPDATE referential actionsSo far the manager has confined itself to forbidding. But forbidding is not always what you want. If BiblioRed withdraws a whole title from the catalog, having to delete its copies one by one first is absurd: the natural thing is for them to go with it.
The referential actions state what the manager should do with the child rows when the parent row is deleted or changes key:
CONSTRAINT fk_copies_book
FOREIGN KEY (book_id) REFERENCES books (book_id)
ON DELETE CASCADE
ON UPDATE CASCADEThey are two independent clauses:
ON DELETE: what to do when the parent row is deleted.ON UPDATE: what to do when the value of the parent row's primary key changes.
If you write neither, NO ACTION applies, the standard's default behavior (the one we saw in the previous section).
A laboratory for trying them out
To experiment without touching BiblioRed, we create two throwaway tables:
CREATE TABLE demo_categories (
category_id INTEGER PRIMARY KEY,
name VARCHAR(40) NOT NULL
);
CREATE TABLE demo_items (
item_id INTEGER PRIMARY KEY,
name VARCHAR(40) NOT NULL,
category_id INTEGER,
CONSTRAINT fk_demo FOREIGN KEY (category_id)
REFERENCES demo_categories (category_id) ON DELETE CASCADE
);
INSERT INTO demo_categories VALUES (1, 'Fiction'), (2, 'Technical');
INSERT INTO demo_items VALUES (10, 'Item A', 1), (11, 'Item B', 1), (12, 'Item C', 2);Initial state: three items, two categories.
| item_id | name | category_id |
|---|---|---|
| 12 | Item C | 2 |
Items A and B have disappeared. The DELETE on one row has deleted three rows in total, and only the first appears in the message. That is the nature of CASCADE: it is powerful and it is silent.
Now let's try SET NULL:
ALTER TABLE demo_items DROP CONSTRAINT fk_demo;
ALTER TABLE demo_items ADD CONSTRAINT fk_demo
FOREIGN KEY (category_id) REFERENCES demo_categories (category_id) ON DELETE SET NULL;
DELETE FROM demo_categories WHERE category_id = 2;
SELECT * FROM demo_items;| item_id | name | category_id |
|---|---|---|
| 12 | Item C | (NULL) |
The item survives; it only loses its reference. We clean up the laboratory:
- The five options, compared
| Action | What it does when the parent row is deleted/updated | Requirement | Risk |
|---|---|---|---|
NO ACTION |
Rejects the operation if children remain. It is the default. The check is performed at the end of the statement, which allows an intermediate trigger to fix the situation | None | None |
RESTRICT |
Rejects the operation if children remain. The check is immediate and cannot be deferred | None | None |
CASCADE |
Propagates: deletes the child rows (ON DELETE) or updates their foreign key (ON UPDATE) |
None | High: a DELETE can take thousands of chained rows with it |
SET NULL |
Sets the children's foreign key to NULL |
The column cannot be NOT NULL |
Medium: rows are left with no reference |
SET DEFAULT |
Sets the foreign key to the column's DEFAULT value |
The column must have a DEFAULT, and that value must exist in the parent table |
Medium: if the default value does not exist, the operation fails |
NO ACTION and RESTRICT: the real difference
In 99% of cases they behave the same: both prevent the operation. The difference is when the check happens:
RESTRICTchecks immediately, as soon as theDELETE's row is processed.NO ACTIONchecks at the end of the statement, and can also be deferred untilCOMMITif the constraint was declaredDEFERRABLE(section 9).
Practical consequence: RESTRICT can never be deferred. If you foresee needing deferrable constraints, use NO ACTION. In BiblioRed we will use RESTRICT where we want an explicit prohibition visible in the CREATE TABLE, because it documents the intent better than leaving the slot empty.
SET DEFAULT: the one hardly anyone uses
-- Requires the column to have a DEFAULT and that value to exist in the parent table
branch_id INTEGER DEFAULT 1 REFERENCES branches (branch_id) ON DELETE SET DEFAULTThe idea is "if this copy's branch disappears, assign it to branch 1". The problem is obvious: if one day somebody deletes branch 1, the action fails and the DELETE is blocked in a way that is hard to diagnose. It exists, you should know about it, and in practice it is used very little. Note: DEFAULT clauses are studied in depth in lesson 04-04.
- Choosing the right action in BiblioRed
The question to ask for each foreign key is always the same:
If the parent row disappears, does the child row still make sense on its own?
- If it does not make sense and is worth nothing →
CASCADE. - If it does not make sense but is valuable (history, accounting, audit) →
RESTRICT. - If it does make sense without the reference →
SET NULL.
Let's apply it to BiblioRed's eight foreign keys.
loans.member_id → RESTRICT
Why deleting a member must not drag their loan history with it. A loan is a fact that happened: on 5 March 2026 a copy went out through the door and came back on 2 April with a €1.40 surcharge. That fact does not stop having happened because the person lets their card lapse.
If we set CASCADE, deregistering a member would destroy historical statistics —loans per year, most borrowed titles, revenue— that management uses to decide purchases. It is the destruction of accounting information disguised as tidying up.
With RESTRICT, the deletion attempt fails, and that forces you to settle the real business question: members are not deleted, they are marked as inactive (active = FALSE, like Ramón Etxebarri). That is what is called a soft delete, and it is the correct practice for any entity with a history.
copies.book_id → CASCADE
Why deleting a book must drag its copies with it. Here the relationship is one of composition: a copy is a physical copy of a book. EJ-3081 is not "an object that happens to be associated with The Map of Time"; it is a copy of The Map of Time. Without the book, the row means nothing: it would be an object with no title, no author and no ISBN.
If the title disappears from the catalog, keeping its fifteen copies would mean preserving referential rubbish. CASCADE is the right action.
And here something interesting happens. Let's try to delete a book that does have loans:
The CASCADE on copies tries to delete copies 1, 2 and 3… but the RESTRICT on loans.copy_id prevents it:
ERROR: update or delete on table "copies" violates foreign key constraint
"fk_loans_copy" on table "loans"The cascade stops when it hits a constraint. That is exactly the desired behavior: a title that was never lent can be withdrawn from the catalog, but one with a history cannot. The schema enforces a real business rule without anyone having programmed it into any application.
The complete picture
| Foreign key | ON DELETE |
ON UPDATE |
Reasoning |
|---|---|---|---|
members.branch_id → branches |
RESTRICT |
CASCADE |
You do not close a branch without reassigning its members first |
books.author_id → authors |
SET NULL |
CASCADE |
The book still exists even if the author record is purged: it becomes a work with no cataloged author, just like "Ensanche Records" |
copies.book_id → books |
CASCADE |
CASCADE |
Composition: the copy does not exist without its work |
copies.branch_id → branches |
RESTRICT |
CASCADE |
Copies have to be moved physically, not deleted |
loans.member_id → members |
RESTRICT |
CASCADE |
History: it is not destroyed |
loans.copy_id → copies |
RESTRICT |
CASCADE |
History: it is not destroyed |
reservations.member_id → members |
CASCADE |
CASCADE |
A reservation is a future intention, not an accounting fact: with no member it means nothing |
reservations.book_id → books |
CASCADE |
CASCADE |
Likewise: without the title, the reservation is useless |
The asymmetry between loans (RESTRICT) and reservations (CASCADE) is the heart of the reasoning: a loan is history and a reservation is future. History is preserved; a future that can no longer happen is discarded.
About the blanket ON UPDATE CASCADE
All our primary keys are surrogate and, by definition, never change value (lesson 02-01). So ON UPDATE CASCADE will never fire. Why put it there, then?
It is cheap insurance. If one day identifiers have to be renumbered during a migration or a merger of two catalogs, the propagation will be automatic instead of a manual, error-prone script. It costs nothing and averts an unlikely but serious disaster. In a schema with natural keys (where the key really can be corrected) ON UPDATE CASCADE stops being insurance and becomes indispensable.
- BiblioRed's final schema with its referential actions
This is the lesson's deliverable. We replace the eight foreign keys we created in 02-02 with their versions carrying referential actions. Run it on your biblioredb:
-- ============================================================
-- BiblioRed - Referential actions
-- Module 2, lesson 02-06. Dialect: PostgreSQL
-- ============================================================
-- members -> branches
ALTER TABLE members DROP CONSTRAINT fk_members_branch;
ALTER TABLE members ADD CONSTRAINT fk_members_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- books -> authors (SET NULL: the work outlives the author record)
ALTER TABLE books DROP CONSTRAINT fk_books_author;
ALTER TABLE books ADD CONSTRAINT fk_books_author
FOREIGN KEY (author_id) REFERENCES authors (author_id)
ON DELETE SET NULL ON UPDATE CASCADE;
-- copies -> books (CASCADE: composition)
ALTER TABLE copies DROP CONSTRAINT fk_copies_book;
ALTER TABLE copies ADD CONSTRAINT fk_copies_book
FOREIGN KEY (book_id) REFERENCES books (book_id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- copies -> branches
ALTER TABLE copies DROP CONSTRAINT fk_copies_branch;
ALTER TABLE copies ADD CONSTRAINT fk_copies_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- loans -> members (RESTRICT: history is not destroyed)
ALTER TABLE loans DROP CONSTRAINT fk_loans_member;
ALTER TABLE loans ADD CONSTRAINT fk_loans_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- loans -> copies (RESTRICT: it stops the cascade from books)
ALTER TABLE loans DROP CONSTRAINT fk_loans_copy;
ALTER TABLE loans ADD CONSTRAINT fk_loans_copy
FOREIGN KEY (copy_id) REFERENCES copies (copy_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- reservations -> members (CASCADE: future intention, not an accounting fact)
ALTER TABLE reservations DROP CONSTRAINT fk_reservations_member;
ALTER TABLE reservations ADD CONSTRAINT fk_reservations_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- reservations -> books
ALTER TABLE reservations DROP CONSTRAINT fk_reservations_book;
ALTER TABLE reservations ADD CONSTRAINT fk_reservations_book
FOREIGN KEY (book_id) REFERENCES books (book_id)
ON DELETE CASCADE ON UPDATE CASCADE;Verification:
Foreign-key constraints:
"fk_loans_copy" FOREIGN KEY (copy_id) REFERENCES copies(copy_id)
ON UPDATE CASCADE ON DELETE RESTRICT
"fk_loans_member" FOREIGN KEY (member_id) REFERENCES members(member_id)
ON UPDATE CASCADE ON DELETE RESTRICTAnd a check that the rules really do apply:
-- Book 338 ("Urban Gardening Handbook") has 2 copies
-- and ZERO loans. The cascade should work.
SELECT COUNT(*) FROM copies WHERE book_id = 338; -- 2
-- We try it… and undo it with a transaction (lesson 06-01)
BEGIN;
DELETE FROM books WHERE book_id = 338;
SELECT COUNT(*) FROM copies WHERE book_id = 338; -- 0: the cascade fired
ROLLBACK;
SELECT COUNT(*) FROM copies WHERE book_id = 338; -- 2: everything restoredHere a practical use of transactions appears for the first time: trying out a destructive operation and undoing it. BEGIN opens the transaction, ROLLBACK cancels it completely. It is the content of lesson 06-01; for now, use it as a safety net.
The SQLite version
SQLite does not support ALTER TABLE ... ADD CONSTRAINT. To add referential actions you have to recreate the tables with the full definition. In a SQLite CREATE TABLE it is written the same way:
CREATE TABLE loans (
loan_id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL
REFERENCES members (member_id) ON DELETE RESTRICT ON UPDATE CASCADE,
copy_id INTEGER NOT NULL
REFERENCES copies (copy_id) ON DELETE RESTRICT ON UPDATE CASCADE,
loan_date TEXT NOT NULL,
due_date TEXT NOT NULL,
return_date TEXT,
surcharge NUMERIC
);And always, always, PRAGMA foreign_keys = ON;. We look at it in section 10.
- Composite foreign keys
If the parent table's primary key is composite (several columns), the foreign key that references it has to be composite too, with the columns in the same order.
Imagine BiblioRed decides to catalog each copy's exact physical location. Shelves are numbered within each branch: there is a shelf A-12 at Central and another A-12 at North, and they are not the same one. The natural primary key is then the pair:
CREATE TABLE shelves (
branch_id INTEGER NOT NULL,
shelf_code VARCHAR(10) NOT NULL,
room VARCHAR(40),
CONSTRAINT pk_shelves PRIMARY KEY (branch_id, shelf_code),
CONSTRAINT fk_shelves_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);And copies would reference it like this:
ALTER TABLE copies ADD COLUMN shelf_code VARCHAR(10);
ALTER TABLE copies ADD CONSTRAINT fk_copies_shelf
FOREIGN KEY (branch_id, shelf_code)
REFERENCES shelves (branch_id, shelf_code)
ON DELETE SET NULL ON UPDATE CASCADE;Three rules you need to know:
- Order matters.
FOREIGN KEY (a, b) REFERENCES t (x, y)pairsawithxandbwithy. Swapping them creates a different and probably absurd constraint. - The table form is mandatory. A constraint affecting two columns does not fit in a single column's declaration.
- Beware of partial
NULLs. By default (MATCH SIMPLE, the standard behavior), if any of the columns isNULL, the constraint is not checked at all. That is: a copy withbranch_id = 2andshelf_code = NULLwould pass validation even if no such shelf exists. If you want to require either both or neither, you have to writeMATCH FULL.
A design observation: in the example above there is an ON DELETE SET NULL on a composite foreign key that branch_id forms part of… and branch_id is NOT NULL in copies. That makes the action fail in practice. It is a good reminder that composite foreign keys are more delicate than they look, and one of the weighty reasons in favor of simple surrogate keys. These modeling decisions are covered in depth in module 4.
These tables are illustrative: do not create them in biblioredb, they are not part of the course schema.
- Deferrable constraints
By default, foreign keys are checked immediately, as each statement runs. That poses a problem in three real situations:
- Circular references. If table A references B and B references A, you cannot insert the first row of either.
- Bulk loads in arbitrary order. A data dump that inserts
loansbeforememberswill fail, even though everything is consistent by the end. - Swaps. Exchanging the identifiers of two rows passes through an invalid intermediate state.
The standard's solution is to declare the constraint deferrable: its checks are postponed until the transaction's COMMIT.
ALTER TABLE loans DROP CONSTRAINT fk_loans_member;
ALTER TABLE loans ADD CONSTRAINT fk_loans_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE NO ACTION ON UPDATE CASCADE
DEFERRABLE INITIALLY DEFERRED;Three possible modes:
| Declaration | Behavior |
|---|---|
(nothing) or NOT DEFERRABLE |
Immediate check, always. The default. |
DEFERRABLE INITIALLY IMMEDIATE |
Immediate by default, but can be deferred in a specific transaction with SET CONSTRAINTS ... DEFERRED |
DEFERRABLE INITIALLY DEFERRED |
Deferred to COMMIT by default |
With the constraint deferred, this works:
BEGIN;
-- We insert the loan BEFORE the member: an invalid intermediate state
INSERT INTO loans (member_id, copy_id, loan_date, due_date)
VALUES (21, 13, '2026-08-02', '2026-08-23');
INSERT INTO members (member_id, first_name, last_name, join_date, branch_id, active)
VALUES (21, 'Berta', 'Colomer', '2026-08-02', 3, TRUE);
COMMIT; -- everything is checked here: correctIf consistency had not been restored by the time COMMIT arrived, the whole transaction is cancelled.
Four warnings:
RESTRICTcan never be deferred. OnlyNO ACTIONacceptsDEFERRABLE. It is the practical difference between the two that we announced in section 5.- Deferred constraints use more memory, because the manager has to remember every pending check until the
COMMIT. - The error appears on commit, not on the guilty statement, which makes diagnosis harder.
- SQLite only supports
DEFERRABLE INITIALLY DEFERRED, and only if foreign keys are enabled.
Advice: do not use them by default. They are a tool for specific cases —bulk loads, migrations, circular references—, not a general convenience. Undo the whole experiment if you tried it, so that biblioredb goes back to its state from section 7:
-- 1) Delete the test data (child first, parent afterwards)
DELETE FROM loans WHERE member_id = 21;
DELETE FROM members WHERE member_id = 21;
-- 2) Restore the non-deferrable constraint
ALTER TABLE loans DROP CONSTRAINT fk_loans_member;
ALTER TABLE loans ADD CONSTRAINT fk_loans_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE RESTRICT ON UPDATE CASCADE;
SELECT COUNT(*) FROM members; -- 10
SELECT COUNT(*) FROM loans; -- 12
- SQLite:
PRAGMA foreign_keys = ON
PRAGMA foreign_keys = ONThis is probably the most important warning in the lesson, and the easiest to overlook.
SQLite accepts the
REFERENCESsyntax, stores it in the schema, shows it in.schema… and DOES NOT ENFORCE IT, unless it is explicitly enabled on every connection.
For backward compatibility with old versions, foreign keys are disabled by default. The result is a schema that looks protected and is not:
sqlite> PRAGMA foreign_keys;
0
sqlite> INSERT INTO loans (member_id, copy_id, loan_date, due_date)
...> VALUES (77, 1, '2026-08-02', '2026-08-23');
sqlite>No error, no warning: a freshly created orphan row, referring to a member 77 who does not exist. Now with the check enabled:
sqlite> PRAGMA foreign_keys = ON;
sqlite> INSERT INTO loans (member_id, copy_id, loan_date, due_date)
...> VALUES (77, 1, '2026-08-02', '2026-08-23');
Error: FOREIGN KEY constraint failedWhat you need to know:
- It is per connection, not per database. Every time you open
sqlite3or every time your application opens a connection, it has to be run again. It is not stored in the file. - It cannot be enabled inside a transaction: if you try, it is silently ignored.
- Data-access libraries do not always do it for you. Some ORMs and drivers enable it automatically; others do not. Check it yourself.
- Put it as the first line of all your
.sqlscripts.
And a specific diagnostic tool, very useful when you inherit a file:
Each line is an existing violation: table, rowid of the guilty row, parent table and index of the foreign key. With no arguments it reviews the whole database. It is the first thing to run when you receive somebody else's SQLite database.
- Detecting and cleaning up orphan rows
Enabling foreign keys prevents new orphans from being created. It does not fix the ones that already exist: in fact, PostgreSQL will refuse to create the constraint while any remain. We need to detect and clean them first.
Let's reproduce the real scenario: BiblioRed imports the historical loans from the spreadsheet into an intermediate table with no constraints, which is how every migration is done.
CREATE TABLE loans_import (
line_no INTEGER,
member_id INTEGER,
copy_code VARCHAR(10),
loan_date DATE
);
INSERT INTO loans_import (line_no, member_id, copy_code, loan_date) VALUES
(1, 14, 'EJ-3081', '2026-02-03'),
(2, 77, 'EJ-3085', '2026-02-05'), -- member does not exist
(3, 16, 'EJ-9999', '2026-02-08'), -- copy does not exist
(4, 15, 'EJ-3084', '2026-02-11'),
(5, NULL, 'EJ-3082', '2026-02-14'), -- unidentified member
(6, 77, 'EJ-3090', '2026-02-19'); -- member does not exist, againDetection with LEFT JOIN ... IS NULL
It is the anti-join pattern from lesson 02-04, applied to data auditing.
-- Rows whose member does not exist (we exclude the NULLs: they are a different problem)
SELECT i.line_no, i.member_id, i.copy_code, i.loan_date
FROM loans_import i
LEFT JOIN members m ON m.member_id = i.member_id
WHERE i.member_id IS NOT NULL
AND m.member_id IS NULL
ORDER BY i.line_no;| line_no | member_id | copy_code | loan_date |
|---|---|---|---|
| 2 | 77 | EJ-3085 | 2026-02-05 |
| 6 | 77 | EJ-3090 | 2026-02-19 |
-- Rows whose copy does not exist
SELECT i.line_no, i.member_id, i.copy_code
FROM loans_import i
LEFT JOIN copies c ON c.code = i.copy_code
WHERE c.copy_id IS NULL
ORDER BY i.line_no;| line_no | member_id | copy_code |
|---|---|---|
| 3 | 16 | EJ-9999 |
The same question with NOT EXISTS, which is just as valid and somewhat more readable:
SELECT i.line_no, i.member_id
FROM loans_import i
WHERE i.member_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM members m WHERE m.member_id = i.member_id);A complete audit report
SELECT i.line_no,
i.member_id,
i.copy_code,
CASE WHEN i.member_id IS NULL THEN 'unidentified member'
WHEN m.member_id IS NULL THEN 'member does not exist'
WHEN c.copy_id IS NULL THEN 'copy does not exist'
ELSE 'valid'
END AS diagnosis
FROM loans_import i
LEFT JOIN members m ON m.member_id = i.member_id
LEFT JOIN copies c ON c.code = i.copy_code
ORDER BY i.line_no;| line_no | member_id | copy_code | diagnosis |
|---|---|---|---|
| 1 | 14 | EJ-3081 | valid |
| 2 | 77 | EJ-3085 | member does not exist |
| 3 | 16 | EJ-9999 | copy does not exist |
| 4 | 15 | EJ-3084 | valid |
| 5 | (NULL) | EJ-3082 | unidentified member |
| 6 | 77 | EJ-3090 | member does not exist |
And the summary for the project meeting, using what we learned in 02-05:
SELECT CASE WHEN i.member_id IS NULL THEN 'unidentified member'
WHEN m.member_id IS NULL THEN 'member does not exist'
WHEN c.copy_id IS NULL THEN 'copy does not exist'
ELSE 'valid' END AS diagnosis,
COUNT(*) AS rows_
FROM loans_import i
LEFT JOIN members m ON m.member_id = i.member_id
LEFT JOIN copies c ON c.code = i.copy_code
GROUP BY 1
ORDER BY rows_ DESC;| diagnosis | rows_ |
|---|---|
| member does not exist | 2 |
| valid | 2 |
| copy does not exist | 1 |
| unidentified member | 1 |
Two thirds of the rows have problems. That is the figure you take into the meeting with management.
The four cleanup strategies
| Strategy | When | How |
|---|---|---|
| Correct | The right value can be deduced (77 was 17, a typo) |
UPDATE row by row, with human judgement |
Set to NULL |
The column allows it and "unknown" is acceptable | UPDATE ... SET member_id = NULL WHERE ... |
| Create the parent row | The parent really did exist and was lost in the migration | INSERT into the parent table |
| Discard | The row cannot be saved | Move it to a quarantine table and delete it |
The golden rule: never delete orphans without saving them first. The missing information may be somewhere else, and once deleted it does not come back.
-- 1) Quarantine: we keep whatever cannot be imported
CREATE TABLE loans_import_rejected AS
SELECT i.*
FROM loans_import i
LEFT JOIN members m ON m.member_id = i.member_id
LEFT JOIN copies c ON c.code = i.copy_code
WHERE i.member_id IS NULL OR m.member_id IS NULL OR c.copy_id IS NULL;
SELECT COUNT(*) FROM loans_import_rejected; -- 4
-- 2) Import only what is valid into the real table
INSERT INTO loans (member_id, copy_id, loan_date, due_date)
SELECT i.member_id, c.copy_id, i.loan_date, i.loan_date + 21
FROM loans_import i
INNER JOIN members m ON m.member_id = i.member_id
INNER JOIN copies c ON c.code = i.copy_code;Notice the detail: the INSERT ... SELECT with an INNER JOIN imports only the rows that match. The orphans are left out by construction, with no need for a WHERE to filter them. It is the deliberate use of a property of the INNER JOIN that in other contexts was a hazard.
Now we clean up the experiment to leave biblioredb as it was (the two imported loans would have identifiers 13 and 14):
DELETE FROM loans WHERE loan_id > 12;
DROP TABLE loans_import_rejected;
DROP TABLE loans_import;
SELECT COUNT(*) FROM loans; -- 12
- Validate in the application or in the database?
It is a recurring argument in development teams, and it deserves a reasoned answer.
The argument for the application: the error messages are better ("That member does not exist, would you like to sign them up?" instead of a technical text about fk_loans_member), validation happens before reaching the database, and with an ORM the relationships are already described in the code.
The argument for the database, which is the decisive one:
- The database is not only yours. Over its lifetime,
biblioredbwill receive writes from the web application, from the nightly import process, from the script somebody runs inpsqlat eleven at night, from the graphical administration tool and from the migration three years from now. Each of those paths would have to reimplement the same validations. One of them will not. - Data outlives code. Applications are rewritten every five or seven years; data is kept for decades. A rule that lives only in the code disappears with it.
- Concurrency. Checking in the application "does member 14 exist?" and then inserting the loan leaves a window between the two operations. If in that interval another session deletes member 14, the check was useless. The database verifies inside the operation, with the appropriate locks. This reasoning is developed in lessons 06-01 and 06-02.
- Bulk operations do not go through the application. An
UPDATEof 40,000 rows runs in SQL. No code validation ever sees it.
The conclusion is not "one or the other", but both, in layers:
flowchart TD
A["User interface<br/>immediate validation, clear messages"] --> B["Application logic<br/>complex business rules"]
B --> C["Database<br/>foreign keys, UNIQUE, NOT NULL, CHECK"]
C --> D[("Data<br/>always consistent")]
A -.->|"can be bypassed"| C
B -.->|"can be bypassed"| C
style C fill:#2d6a4f,color:#ffffff
The application validates for the person: useful messages, forms that guide, errors caught before submitting. The database validates for the data: it is the last line of defense, the one nobody can bypass, the one that is still there when the application changes.
A practical case: BiblioRed validates in the web form that the email address has an email format (the database does not check that well), and the database guarantees that it is UNIQUE and that the branch_id exists (the application cannot guarantee that reliably). Each layer does what it knows how to do.
A final note: CHECK constraints —which verify conditions on the values, such as "the status must be one of these four" or "the return date cannot be earlier than the loan date"— are the other great validation tool in the database. They are covered in depth in lesson 04-04, alongside DEFAULT and the criteria for choosing types.
Common Mistakes and Tips
- Forgetting
PRAGMA foreign_keys = ONin SQLite. The schema looks correct and protects nothing. It is trap number one of this lesson, and it has to be repeated on every connection. - Adding
ON DELETE CASCADEfor convenience. "That way deleting does not raise errors" is the worst possible reason. ADELETEon one row can take thousands of chained rows with it, without warning and without a recycle bin. - Deleting entities with a history. Members, customers, products sold are not deleted: they are marked as inactive.
RESTRICTis your ally precisely because it forces you to face that decision. - Confusing
RESTRICTwithNO ACTION. They behave the same except in one decisive detail:RESTRICTcannot be deferred. - Using
SET NULLon aNOT NULLcolumn. PostgreSQL rejects the definition; in other managers the error appears later, when the deletion is attempted. - Believing that a null foreign key is an error. It is not:
NULLmeans "points at nobody". If the reference must be mandatory, addNOT NULL. - Leaving constraints unnamed. The day you have to do a
DROP CONSTRAINT, you will have to go and look up the automatic name in the catalog. - Referencing a column with no
UNIQUE. The manager rejects it, and rightly so: the reference would be ambiguous. - Deleting orphans without saving them. Always move them to a quarantine table first. What is deleted does not come back.
- Relying on application validation alone. There will be another write path. There always is.
- Tip: document the referential action you chose with a comment in the
CREATE TABLE. Two years from now, "why is this oneRESTRICTand that oneCASCADE?" will be a real question, and the answer is a business decision, not a technical one. - Tip: when you inherit a database, the very first thing to run is the orphan audit for each foreign key (or
PRAGMA foreign_key_checkin SQLite). In thirty seconds it will tell you what quality of data you are working with.
Exercises
Exercise 1: Choosing the referential action
BiblioRed wants to add three new tables. For each foreign key, decide the ON DELETE and justify the choice in one sentence.
reviews (review_id, member_id, book_id, content, rating, review_date): reviews written by members about the books.fines (fine_id, loan_id, amount, issue_date, paid): financial penalties arising from a loan.events (event_id, branch_id, title, event_date): cultural activities organized by each branch.registrations (registration_id, event_id, member_id, registration_date): members signed up for those events.
Exercise 2: Predicting the effect of a deletion
With the final schema from section 7 and the course data set, say what happens with each statement and how many rows are affected in total.
DELETE FROM authors WHERE author_id = 8;(Marina Escolá, no works)DELETE FROM authors WHERE author_id = 4;(Óscar Barreda, two works)DELETE FROM books WHERE book_id = 339;(Ensanche Records, 1 copy with no loans)DELETE FROM books WHERE book_id = 331;(The Map of Time, 3 copies, 4 loans, 2 reservations)DELETE FROM members WHERE member_id = 20;(Elena Roig, no loans and no reservations)DELETE FROM members WHERE member_id = 16;(Nuria Bastos, 2 loans, 1 reservation)DELETE FROM branches WHERE branch_id = 4;(East, 1 member, 2 copies)
Exercise 3: Audit and cleanup
BiblioRed has received a file of members from another library to take on. Create it as an intermediate table:
CREATE TABLE members_import (
line_no INTEGER,
first_name VARCHAR(60),
last_name VARCHAR(80),
email VARCHAR(120),
branch_id INTEGER
);
INSERT INTO members_import (line_no, first_name, last_name, email, branch_id) VALUES
(1, 'Rosa', 'Cabanes', '[email protected]', 2),
(2, 'Teo', 'Ninot', '[email protected]', 9),
(3, 'Amina', 'Bakri', '[email protected]', 1),
(4, 'Lluc', 'Ferrer', '[email protected]', 3),
(5, 'Selma', 'Duarte', NULL, 7),
(6, 'Jordi', 'Pons', '[email protected]', NULL);- Write a query that detects the rows whose
branch_iddoes not exist. - Write a query that detects the rows whose
emailis already in use by a current member. - Write an audit report with a
diagnosiscolumn that classifies each row. - Import only the valid rows and check how many got in. Afterwards, leave
biblioredbas it was.
Solutions
Solution 1
| Table | Foreign key | ON DELETE |
Justification |
|---|---|---|---|
reviews |
member_id → members |
SET NULL |
The review is valuable to other readers even if its author lets their card lapse: it becomes anonymous. (CASCADE would be defensible if the privacy policy required erasing every trace of the member; that is a legal decision, not a technical one.) |
reviews |
book_id → books |
CASCADE |
A review of a book that is no longer in the catalog has no possible reader. |
fines |
loan_id → loans |
RESTRICT |
It is a financial record. Besides, loans.member_id is already RESTRICT, so the protection is consistent along the whole chain. |
events |
branch_id → branches |
RESTRICT (or SET NULL) |
Past events are a record of activity; deleting them when a branch closes would destroy the annual statistics. |
registrations |
event_id → events |
CASCADE |
With no event, the registration means nothing. |
registrations |
member_id → members |
CASCADE |
Same as reservations: it is a future intention, not an accounting fact. |
The pattern that emerges: financial and historical facts → RESTRICT; intentions and accessory items → CASCADE; content with value of its own → SET NULL.
Solution 2
| # | What happens | Rows affected |
|---|---|---|
| 1 | Success. Marina Escolá has no works, so the SET NULL touches nothing. |
1 (the author) |
| 2 | Success with SET NULL. Books 334 and 338 survive with author_id = NULL; their copies and loans are untouched. |
3 (1 author + 2 books modified) |
| 3 | Success with CASCADE. The book is deleted and, in cascade, its copy EJ-3095, which has no loans. There are no reservations for 339. |
2 (1 book + 1 copy) |
| 4 | ERROR. The cascade tries to delete copies 1, 2 and 3, but the RESTRICT on fk_loans_copy prevents it: there are 4 loans pointing at them. Nothing is deleted. |
0 |
| 5 | Success. Elena Roig has nothing associated with her. | 1 |
| 6 | ERROR. The RESTRICT on fk_loans_member blocks the deletion because of her 2 loans. The reservation would have been deleted in cascade, but the whole operation is cancelled. |
0 |
| 7 | ERROR. The RESTRICT on fk_members_branch (Lucía Vendrell is signed up at East) and the one on fk_copies_branch (2 copies) prevent it. Members and copies have to be reassigned first. |
0 |
An important observation about cases 4, 6 and 7: the failure is atomic. Even if the cascade had started deleting rows before hitting the RESTRICT, everything is undone: the statement is a unit. That is guaranteed by transactions, the subject of lesson 06-01.
Solution 3
-- 1 Branch does not exist (excluding the NULLs, which are a different case)
SELECT i.line_no, i.last_name, i.branch_id
FROM members_import i
LEFT JOIN branches br ON br.branch_id = i.branch_id
WHERE i.branch_id IS NOT NULL
AND br.branch_id IS NULL
ORDER BY i.line_no;| line_no | last_name | branch_id |
|---|---|---|
| 2 | Ninot | 9 |
| 5 | Duarte | 7 |
-- 2 Email already in use: it would violate uq_members_email
SELECT i.line_no, i.last_name, i.email
FROM members_import i
INNER JOIN members m ON m.email = i.email
ORDER BY i.line_no;| line_no | last_name | |
|---|---|---|
| 4 | Ferrer | [email protected] |
-- 3 Audit report
SELECT i.line_no,
i.first_name || ' ' || i.last_name AS member,
CASE WHEN i.branch_id IS NULL THEN 'no branch assigned'
WHEN br.branch_id IS NULL THEN 'branch does not exist'
WHEN m.member_id IS NOT NULL THEN 'duplicate email'
ELSE 'valid'
END AS diagnosis
FROM members_import i
LEFT JOIN branches br ON br.branch_id = i.branch_id
LEFT JOIN members m ON m.email = i.email
ORDER BY i.line_no;| line_no | member | diagnosis |
|---|---|---|
| 1 | Rosa Cabanes | valid |
| 2 | Teo Ninot | branch does not exist |
| 3 | Amina Bakri | valid |
| 4 | Lluc Ferrer | duplicate email |
| 5 | Selma Duarte | branch does not exist |
| 6 | Jordi Pons | no branch assigned |
Note: row 6 violates no foreign key —NULL is a valid reference— but it would violate the NOT NULL on members.branch_id. They are two different constraints and both have to be audited.
-- 4 Import only what is valid
INSERT INTO members (first_name, last_name, email, join_date, branch_id, active)
SELECT i.first_name, i.last_name, i.email, '2026-08-02', i.branch_id, TRUE
FROM members_import i
INNER JOIN branches br ON br.branch_id = i.branch_id
WHERE NOT EXISTS (SELECT 1 FROM members m WHERE m.email = i.email);Rosa Cabanes and Amina Bakri get in. The INNER JOIN with branches discards rows 2, 5 and 6 by construction, and the NOT EXISTS discards row 4.
-- Check and clean up
SELECT COUNT(*) FROM members; -- 12
DELETE FROM members WHERE last_name IN ('Cabanes', 'Bakri');
DROP TABLE members_import;
SELECT COUNT(*) FROM members; -- 10Conclusion
We close the module with the guarantees that keep everything above true as time passes:
- An orphan row is a foreign key pointing at something that does not exist. They were born daily in BiblioRed's spreadsheet through typing, deletions and renumberings, and their damage is silent:
INNER JOINs remove them without warning and the totals stop adding up. - A foreign key is declared in column form or, better, in table form with a proper name (
CONSTRAINT fk_...), and it can only reference columns withPRIMARY KEYorUNIQUE. When you add it withALTER TABLE, the manager validates every existing row. - The manager checks on
INSERTandUPDATEof the child table, and onDELETEandUPDATEof the parent table's primary key. A null foreign key is legal: if the reference must be mandatory, you have to addNOT NULL. - The five referential actions —
NO ACTION,RESTRICT,CASCADE,SET NULLandSET DEFAULT— define what happens to the children when the parent disappears.RESTRICTcannot be deferred; that is its only real difference fromNO ACTION. - The choice criterion in BiblioRed: a loan is history (
RESTRICT) and a reservation is future (CASCADE); a copy is part of a book (CASCADE) and a book outlives its author's record (SET NULL). A member is not deleted: they are marked as inactive. - Composite foreign keys require the table form, respect the column order and, with
MATCH SIMPLE, are not checked if any column is null. - Deferrable constraints (
DEFERRABLE INITIALLY DEFERRED) postpone the check toCOMMIT, and they exist for circular references, bulk loads and swaps. They are not a general convenience. - SQLite does not enforce foreign keys unless
PRAGMA foreign_keys = ONis enabled, on every connection.PRAGMA foreign_key_checkaudits a whole database. - The orphans that already exist are detected with the
LEFT JOIN ... IS NULLpattern (orNOT EXISTS), classified withCASE WHEN, kept in quarantine and only then discarded. AnINSERT ... SELECTwith anINNER JOINimports only what is valid, by construction. - And the underlying conclusion: you validate in both layers. The application validates for the person; the database is the last line of defense, because there will be other write paths, because data outlives code and because only the manager can verify inside the operation, with no concurrency windows.
That brings module 2 to a close. You have travelled the complete road of the relational world: the theory of the model and its integrity rules, the SQL language and the creation of the schema, CRUD on a single table, joining several tables with JOINs and subqueries, summarizing with aggregates and grouping, and the referential guarantees that hold it all up. biblioredb is no longer an empty database: it is an information system with seven tables, consistent data, management reports and defenses of its own. In module 3, Non-Relational Databases, we change worlds: we will see what NoSQL is, which families exist, how data is modeled when there is no fixed schema and no foreign keys to protect it —and what is gained and what is lost in that bargain—. BiblioRed comes with us: its reviews and its activity log are, as we decided in lesson 01-02, the perfect use case for MongoDB.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
