Warm-up · Activity 1 of 7
Warm-up from the last lesson. Persuasion was added with copies NULL. What happens?
ALTER TABLE books ALTER COLUMN copies SET NOT NULL// B4.5 · ~30 min · Beginner
After this build you can put rules on a database that already holds bad data: find the rows that break them, fix them, add the rules, and prove they hold.
Lesson 5 of 5 in B4 Changing data and schema
You will be able to
Warm-up · Activity 1 of 7
ALTER TABLE books ALTER COLUMN copies SET NOT NULLPredict · Activity 2 of 7
Practice · Activity 3 of 7
SELECT l.id, l.member_id
FROM loans l
LEFT JOIN members m ON m.id = l.member_id
WHERE m.id IS ____;Practice · Activity 4 of 7
Practice · Activity 5 of 7
Brain teaser · Activity 6 of 7
ALTER TABLE loans ADD CHECK (returned_on >= loaned_on)Apply · Activity 7 of 7
Check your work against this list
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
setup.sql loads the library of module B1 without any rules, plus three rows that got in: a loan for member 9, a loan with swapped dates and a book without copies. main.sql finds them, fixes them, adds keys, foreign keys, a CHECK, a NOT NULL, a default and a new column, and lists what loans now enforces. On your computer: psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sql.
main.sql
-- 1. Find the rows that would break the new rules.
SELECT l.id, l.member_id
FROM loans l
LEFT JOIN members m ON m.id = l.member_id
WHERE m.id IS NULL;
SELECT id, loaned_on, returned_on FROM loans WHERE returned_on < loaned_on;
SELECT id, title FROM books WHERE copies IS NULL;
-- 2. Fix them.
DELETE FROM loans WHERE id = 6;
UPDATE loans SET loaned_on = '2026-09-15', returned_on = '2026-09-25' WHERE id = 7;
UPDATE books SET copies = 1 WHERE id = 7;
-- 3. Keys first, then the foreign keys and rules that rely on them.
ALTER TABLE books ADD PRIMARY KEY (id);
ALTER TABLE members ADD PRIMARY KEY (id);
ALTER TABLE loans ADD PRIMARY KEY (id);
ALTER TABLE loans ADD FOREIGN KEY (book_id) REFERENCES books (id);
ALTER TABLE loans ADD FOREIGN KEY (member_id) REFERENCES members (id);
ALTER TABLE loans ADD CHECK (returned_on >= loaned_on);
ALTER TABLE books ALTER COLUMN copies SET NOT NULL;
ALTER TABLE books ALTER COLUMN copies SET DEFAULT 1;
-- 4. A new column for members.
ALTER TABLE members ADD COLUMN active boolean NOT NULL DEFAULT true;
-- 5. What loans now enforces.
SELECT contype, count(*) FROM pg_constraint
WHERE conrelid = 'loans'::regclass
GROUP BY contype
ORDER BY contype;
setup.sql
CREATE TABLE books (
id integer,
title text,
author text,
year integer,
genre text,
copies integer
);
INSERT INTO books VALUES
(1, 'Dune', 'Frank Herbert', 1965, 'sci-fi', 2),
(2, 'Emma', 'Jane Austen', 1815, 'classic', 1),
(3, 'Kindred', 'Octavia E. Butler', 1979, 'sci-fi', 1),
(4, 'Beloved', 'Toni Morrison', 1987, 'fiction', 1),
(5, 'The Hobbit', 'J. R. R. Tolkien', 1937, 'fantasy', 3),
(6, 'Neuromancer', 'William Gibson', 1984, 'sci-fi', 1);
CREATE TABLE members (
id integer,
name text,
email text,
city text,
joined date
);
INSERT INTO members VALUES
(1, 'Ada', 'ada@example.com', 'Berlin', '2025-01-15'),
(2, 'Grace', 'grace@example.com', 'Munich', '2025-03-02'),
(3, 'Linus', NULL, 'Berlin', '2026-02-10'),
(4, 'Margaret', 'margaret@example.com', NULL, '2026-05-20');
CREATE TABLE loans (
id integer,
book_id integer,
member_id integer,
loaned_on date,
returned_on date
);
INSERT INTO loans VALUES
(1, 1, 1, '2026-09-01', '2026-09-10'),
(2, 3, 1, '2026-09-12', NULL),
(3, 2, 2, '2026-09-05', '2026-09-20'),
(4, 1, 3, '2026-09-15', NULL),
(5, 6, 2, '2026-09-18', NULL);
-- Rows that got in while nothing was checked.
INSERT INTO books VALUES (7, 'Persuasion', 'Jane Austen', 1817, 'classic', NULL);
INSERT INTO loans VALUES
(6, 5, 9, '2026-09-20', NULL),
(7, 4, 4, '2026-09-25', '2026-09-15');
Run it with
psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sqlOutput
id | member_id
----+-----------
6 | 9
(1 row)
id | loaned_on | returned_on
----+------------+-------------
7 | 2026-09-25 | 2026-09-15
(1 row)
id | title
----+------------
7 | Persuasion
(1 row)
contype | count
---------+-------
c | 1
f | 2
n | 1
p | 1
(4 rows)Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads PostgreSQL for your browser (up to 10.1 MB) and keeps it cached. Every run starts from an empty database. Your code stays on your device.
Exercise 1 of 2
The starter finds the three bad rows. Fix them: loan 6 cannot be traced to a member, so remove it; loan 7 went out on 2026-09-15 and came back on 2026-09-25, the dates were swapped; Persuasion has 1 copy. Keep every other row.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads PostgreSQL for your browser (up to 10.1 MB) and keeps it cached. Every run starts from an empty database. Your code stays on your device.
DELETE FROM loans WHERE id = 6; removes exactly the loan for member 9.
For loan 7, set both dates: UPDATE loans SET loaned_on = '2026-09-15', returned_on = '2026-09-25' WHERE id = 7;
Persuasion is book 7: UPDATE books SET copies = 1 WHERE id = 7;
One way to solve it. Yours can look different and still pass the checks.
DELETE FROM loans WHERE id = 6;
UPDATE loans SET loaned_on = '2026-09-15', returned_on = '2026-09-25' WHERE id = 7;
UPDATE books SET copies = 1 WHERE id = 7;
Install PostgreSQL 18 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.sql
-- Loans whose member does not exist.
SELECT l.id, l.member_id
FROM loans l
LEFT JOIN members m ON m.id = l.member_id
WHERE m.id IS NULL;
-- Loans that came back before they went out.
SELECT id, loaned_on, returned_on FROM loans WHERE returned_on < loaned_on;
-- Books without copies.
SELECT id, title FROM books WHERE copies IS NULL;
test.sql
-- test: Every loan names an existing member
SELECT NOT EXISTS (SELECT 1 FROM loans l LEFT JOIN members m ON m.id = l.member_id WHERE m.id IS NULL);
-- test: Loan 7 went out on 2026-09-15 and came back on 2026-09-25
SELECT loaned_on = '2026-09-15' AND returned_on = '2026-09-25' FROM loans WHERE id = 7;
-- test: No loan comes back before it went out
SELECT NOT EXISTS (SELECT 1 FROM loans WHERE returned_on < loaned_on);
-- test: Persuasion has 1 copy
SELECT copies = 1 FROM books WHERE id = 7;
-- test: Six loans, seven books and four members remain
SELECT (SELECT count(*) FROM loans) = 6 AND (SELECT count(*) FROM books) = 7 AND (SELECT count(*) FROM members) = 4;
setup.sql
CREATE TABLE books (
id integer,
title text,
author text,
year integer,
genre text,
copies integer
);
INSERT INTO books VALUES
(1, 'Dune', 'Frank Herbert', 1965, 'sci-fi', 2),
(2, 'Emma', 'Jane Austen', 1815, 'classic', 1),
(3, 'Kindred', 'Octavia E. Butler', 1979, 'sci-fi', 1),
(4, 'Beloved', 'Toni Morrison', 1987, 'fiction', 1),
(5, 'The Hobbit', 'J. R. R. Tolkien', 1937, 'fantasy', 3),
(6, 'Neuromancer', 'William Gibson', 1984, 'sci-fi', 1);
CREATE TABLE members (
id integer,
name text,
email text,
city text,
joined date
);
INSERT INTO members VALUES
(1, 'Ada', 'ada@example.com', 'Berlin', '2025-01-15'),
(2, 'Grace', 'grace@example.com', 'Munich', '2025-03-02'),
(3, 'Linus', NULL, 'Berlin', '2026-02-10'),
(4, 'Margaret', 'margaret@example.com', NULL, '2026-05-20');
CREATE TABLE loans (
id integer,
book_id integer,
member_id integer,
loaned_on date,
returned_on date
);
INSERT INTO loans VALUES
(1, 1, 1, '2026-09-01', '2026-09-10'),
(2, 3, 1, '2026-09-12', NULL),
(3, 2, 2, '2026-09-05', '2026-09-20'),
(4, 1, 3, '2026-09-15', NULL),
(5, 6, 2, '2026-09-18', NULL);
-- Rows that got in while nothing was checked.
INSERT INTO books VALUES (7, 'Persuasion', 'Jane Austen', 1817, 'classic', NULL);
INSERT INTO loans VALUES
(6, 5, 9, '2026-09-20', NULL),
(7, 4, 4, '2026-09-25', '2026-09-15');
psql runs the files on your own PostgreSQL server. Use an empty database you can throw away: add -d and its name to the command.
Run the program:
psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sqlThere is no command for the checks on your computer yet. They are in test.sql: each check query prints t when it passes.
Exercise 2 of 2
The data is clean now. Add the rules with ALTER TABLE: a primary key on id in books, members and loans; foreign keys from loans.book_id to books and from loans.member_id to members; CHECK (returned_on >= loaned_on) on loans; copies NOT NULL with default 1; and a new column active in members, boolean, NOT NULL, true for everyone.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads PostgreSQL for your browser (up to 10.1 MB) and keeps it cached. Every run starts from an empty database. Your code stays on your device.
Primary keys first: ALTER TABLE books ADD PRIMARY KEY (id); and the same for members and loans.
ALTER TABLE loans ADD FOREIGN KEY (member_id) REFERENCES members (id); and the same for book_id.
ALTER COLUMN copies SET NOT NULL and SET DEFAULT 1 are two statements; ADD COLUMN active boolean NOT NULL DEFAULT true fills the four members.
One way to solve it. Yours can look different and still pass the checks.
ALTER TABLE books ADD PRIMARY KEY (id);
ALTER TABLE members ADD PRIMARY KEY (id);
ALTER TABLE loans ADD PRIMARY KEY (id);
ALTER TABLE loans ADD FOREIGN KEY (book_id) REFERENCES books (id);
ALTER TABLE loans ADD FOREIGN KEY (member_id) REFERENCES members (id);
ALTER TABLE loans ADD CHECK (returned_on >= loaned_on);
ALTER TABLE books ALTER COLUMN copies SET NOT NULL;
ALTER TABLE books ALTER COLUMN copies SET DEFAULT 1;
ALTER TABLE members ADD COLUMN active boolean NOT NULL DEFAULT true;
Install PostgreSQL 18 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.sql
-- The data is clean. Add the rules here, keys first.
SELECT count(*) FROM loans;
test.sql
-- test: books, members and loans have a primary key on id
SELECT EXISTS (SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) WHERE c.conrelid = 'books'::regclass AND c.contype = 'p' AND cardinality(c.conkey) = 1 AND a.attname = 'id') AND EXISTS (SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) WHERE c.conrelid = 'members'::regclass AND c.contype = 'p' AND cardinality(c.conkey) = 1 AND a.attname = 'id') AND EXISTS (SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) WHERE c.conrelid = 'loans'::regclass AND c.contype = 'p' AND cardinality(c.conkey) = 1 AND a.attname = 'id');
-- test: loans.book_id refers to books
SELECT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'loans'::regclass AND contype = 'f' AND confrelid = 'books'::regclass);
-- test: loans.member_id refers to members
SELECT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'loans'::regclass AND contype = 'f' AND confrelid = 'members'::regclass);
-- test: A CHECK constraint guards the dates of loans
SELECT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'loans'::regclass AND contype = 'c');
-- test: copies is NOT NULL with default 1
SELECT a.attnotnull AND pg_get_expr(d.adbin, d.adrelid) = '1' FROM pg_attribute a JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum WHERE a.attrelid = 'books'::regclass AND a.attname = 'copies';
-- test: Every member is active, and active is NOT NULL
SELECT (SELECT count(*) FROM members WHERE active) = 4 AND (SELECT attnotnull FROM pg_attribute WHERE attrelid = 'members'::regclass AND attname = 'active');
setup.sql
CREATE TABLE books (
id integer,
title text,
author text,
year integer,
genre text,
copies integer
);
INSERT INTO books VALUES
(1, 'Dune', 'Frank Herbert', 1965, 'sci-fi', 2),
(2, 'Emma', 'Jane Austen', 1815, 'classic', 1),
(3, 'Kindred', 'Octavia E. Butler', 1979, 'sci-fi', 1),
(4, 'Beloved', 'Toni Morrison', 1987, 'fiction', 1),
(5, 'The Hobbit', 'J. R. R. Tolkien', 1937, 'fantasy', 3),
(6, 'Neuromancer', 'William Gibson', 1984, 'sci-fi', 1);
CREATE TABLE members (
id integer,
name text,
email text,
city text,
joined date
);
INSERT INTO members VALUES
(1, 'Ada', 'ada@example.com', 'Berlin', '2025-01-15'),
(2, 'Grace', 'grace@example.com', 'Munich', '2025-03-02'),
(3, 'Linus', NULL, 'Berlin', '2026-02-10'),
(4, 'Margaret', 'margaret@example.com', NULL, '2026-05-20');
CREATE TABLE loans (
id integer,
book_id integer,
member_id integer,
loaned_on date,
returned_on date
);
INSERT INTO loans VALUES
(1, 1, 1, '2026-09-01', '2026-09-10'),
(2, 3, 1, '2026-09-12', NULL),
(3, 2, 2, '2026-09-05', '2026-09-20'),
(4, 1, 3, '2026-09-15', NULL),
(5, 6, 2, '2026-09-18', NULL);
-- Rows that got in while nothing was checked.
INSERT INTO books VALUES (7, 'Persuasion', 'Jane Austen', 1817, 'classic', NULL);
INSERT INTO loans VALUES
(6, 5, 9, '2026-09-20', NULL),
(7, 4, 4, '2026-09-25', '2026-09-15');
-- Step 1: the bad rows, fixed.
DELETE FROM loans WHERE id = 6;
UPDATE loans SET loaned_on = '2026-09-15', returned_on = '2026-09-25' WHERE id = 7;
UPDATE books SET copies = 1 WHERE id = 7;
psql runs the files on your own PostgreSQL server. Use an empty database you can throw away: add -d and its name to the command.
Run the program:
psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sqlThere is no command for the checks on your computer yet. They are in test.sql: each check query prints t when it passes.
ALTER TABLE members ADD PRIMARY KEY (id);
ALTER TABLE loans ADD FOREIGN KEY (member_id) REFERENCES members (id);
What psql prints
ERROR: insert or update on table "loans" violates foreign key constraint "loans_member_id_fkey"Why, and the fix
The new foreign key is checked against every loan, and loan 6 names member 9, who does not exist; the DETAIL line names the key. Find such rows with LEFT JOIN members … WHERE m.id IS NULL, fix or delete them, then add the foreign key.
ALTER TABLE loans ADD FOREIGN KEY (book_id) REFERENCES books (id);
What psql prints
ERROR: there is no unique constraint matching given keys for referenced table "books"Why, and the fix
A foreign key must refer to a primary key or UNIQUE column, so that each value finds one row. Add ALTER TABLE books ADD PRIMARY KEY (id); first.
ALTER TABLE loans ADD CHECK (returned_on >= loaned_on);
What psql prints
ERROR: check constraint "loans_check" of relation "loans" is violated by some rowWhy, and the fix
Loan 7 came back before it went out. List such rows with WHERE returned_on < loaned_on, correct the dates with UPDATE, then add the CHECK again.
PostgreSQL in the browser: PGlite 0.5.8 (PostgreSQL 18.3), Apache-2.0 and PostgreSQL License. Licence and source
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.