Warm-up · Activity 1 of 7
Warm-up from the last lesson. Ada and Grace have two loans each, Linus one, Margaret none. Which member is missing from the result of this inner join?
SELECT m.name, l.id
FROM members m
JOIN loans l ON l.member_id = m.id;// B3.3 · ~30 min · Beginner
After this lesson you can keep the rows a join drops, read their empty columns, place a filter so they stay, and list members or books with no partner.
You will be able to
Warm-up · Activity 1 of 7
SELECT m.name, l.id
FROM members m
JOIN loans l ON l.member_id = m.id;Predict · Activity 2 of 7
SELECT m.name, l.id
FROM members m
LEFT JOIN loans l ON l.member_id = m.id;Practice · Activity 3 of 7
SELECT b.title, l.id
FROM books b
____ JOIN loans l ON l.book_id = b.id
ORDER BY b.id, l.id;Practice · Activity 4 of 7
Practice · Activity 5 of 7
Brain teaser · Activity 6 of 7
-- books: 1 Dune, 2 Emma, 3 Kindred, 4 Beloved, 5 The Hobbit, 6 Neuromancer
-- members: 1 Ada, 2 Grace, 3 Linus, 4 Margaret
-- loans (id, book_id, member_id, returned_on):
-- (1, 1, 1, 2026-09-10), (2, 3, 1, NULL), (3, 2, 2, 2026-09-20),
-- (4, 1, 3, NULL), (5, 6, 2, NULL)
SELECT m.name
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
WHERE l.returned_on IS NULL;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 from module B1 (books, members, loans) and the shelves table of the last lesson. main.sql lists every member with their loans, finds the members who never borrowed anything, and joins books and shelves so that the rows without a partner on either side stay. On your computer: psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sql.
main.sql
-- 1. Every member, with their loans if they have any.
SELECT m.name, l.id AS loan_id, l.loaned_on
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
ORDER BY m.name, l.id;
-- 2. Members who have never borrowed a book (anti-join).
SELECT m.name
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
WHERE l.id IS NULL;
-- 3. Books and shelves: unmatched rows from both sides.
SELECT genre, b.title, s.floor
FROM books b
FULL JOIN shelves s USING (genre)
ORDER BY genre, b.title;
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);
CREATE TABLE shelves (
genre text,
floor integer
);
INSERT INTO shelves VALUES
('classic', 1),
('fantasy', 2),
('sci-fi', 2),
('poetry', 3);
Run it with
psql -X -q -v ON_ERROR_STOP=1 -f setup.sql -f main.sqlOutput
name | loan_id | loaned_on
----------+---------+------------
Ada | 1 | 2026-09-01
Ada | 2 | 2026-09-12
Grace | 3 | 2026-09-05
Grace | 5 | 2026-09-18
Linus | 4 | 2026-09-15
Margaret | |
(6 rows)
name
----------
Margaret
(1 row)
genre | title | floor
---------+-------------+-------
classic | Emma | 1
fantasy | The Hobbit | 2
fiction | Beloved |
poetry | | 3
sci-fi | Dune | 2
sci-fi | Kindred | 2
sci-fi | Neuromancer | 2
(7 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
List every member with the title of each book they borrowed, sorted by name and then title. Members with no loan must appear once, with an empty title. The starter uses LEFT JOIN for loans, but Margaret is still missing. Find out why and fix it.
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.
After the LEFT JOIN, Margaret’s row has NULL in l.book_id.
The next join is an inner join: b.id = NULL is never true, so it drops her row again.
Make the second join an outer join as well: LEFT JOIN books b ON b.id = l.book_id.
One way to solve it. Yours can look different and still pass the checks.
-- Every member, with the titles they borrowed.
SELECT m.name, b.title
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
LEFT JOIN books b ON b.id = l.book_id
ORDER BY m.name, b.title;
Install PostgreSQL 18 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.sql
-- Every member, with the titles they borrowed.
SELECT m.name, b.title
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
JOIN books b ON b.id = l.book_id
ORDER BY m.name, b.title;
test.sql
-- test: All four members are listed, Margaret once with an empty title
-- output:
-- name | title
-- ----------+-------------
-- Ada | Dune
-- Ada | Kindred
-- Grace | Emma
-- Grace | Neuromancer
-- Linus | Dune
-- Margaret |
-- (6 rows)
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);
CREATE TABLE shelves (
genre text,
floor integer
);
INSERT INTO shelves VALUES
('classic', 1),
('fantasy', 2),
('sci-fi', 2),
('poetry', 3);
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
Write two queries, each sorted by title. First: the titles of the books that were never loaned. Second: the titles of the books on the shelf right now, that is, books with no open loan (returned_on IS NULL). Use LEFT JOIN with WHERE l.id IS NULL in both; in the second, the open-loan test belongs in ON.
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.
An anti-join: LEFT JOIN loans, then keep the rows where l.id IS NULL, the books that found no loan.
For the second query, only open loans should count as a match, so l.returned_on IS NULL goes into ON with AND.
Emma was loaned and returned: with the test in ON, she finds no open loan and comes back.
One way to solve it. Yours can look different and still pass the checks.
SELECT b.title
FROM books b
LEFT JOIN loans l ON l.book_id = b.id
WHERE l.id IS NULL
ORDER BY b.title;
SELECT b.title
FROM books b
LEFT JOIN loans l
ON l.book_id = b.id
AND l.returned_on IS NULL
WHERE l.id IS NULL
ORDER BY b.title;
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 books that have been loaned.
SELECT b.title
FROM books b
JOIN loans l ON l.book_id = b.id
ORDER BY b.title;
test.sql
-- test: First Beloved and The Hobbit, then Beloved, Emma and The Hobbit
-- output:
-- title
-- ------------
-- Beloved
-- The Hobbit
-- (2 rows)
--
-- title
-- ------------
-- Beloved
-- Emma
-- The Hobbit
-- (3 rows)
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);
CREATE TABLE shelves (
genre text,
floor integer
);
INSERT INTO shelves VALUES
('classic', 1),
('fantasy', 2),
('sci-fi', 2),
('poetry', 3);
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.
SELECT m.name, l.id
FROM members m
OUTER JOIN loans l ON l.member_id = m.id;
What psql prints
ERROR: syntax error at or near "OUTER"Why, and the fix
OUTER is optional noise after LEFT, RIGHT or FULL, and cannot stand alone: PostgreSQL has to know which side to keep. Write LEFT JOIN (or LEFT OUTER JOIN) to keep every member.
SELECT m.name, l.id
FROM members m
LEFT JOIN loans l
WHERE l.member_id = m.id;
What psql prints
ERROR: syntax error at or near "WHERE"Why, and the fix
An outer join, like an inner one, needs its condition in ON (or USING) right after the table. Even with a CROSS JOIN, the condition in WHERE would run after the join and drop Margaret. Write LEFT JOIN loans l ON l.member_id = m.id.
SELECT m.name, b.title
FROM members m
LEFT JOIN loans l ON l.book_id = b.id
LEFT JOIN books b ON b.id = l.book_id;
What psql prints
ERROR: missing FROM-clause entry for table "b"Why, and the fix
Joins are built left to right, and each ON can only use the tables joined so far. loans must be joined to members through l.member_id = m.id; books comes next and uses l.book_id.
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.