In the previous lesson we learned to interrogate one table. But the questions BiblioRed really cares about do not fit inside a single table: "who has copy EJ-3081 right now?", "which members have never borrowed anything?", "which author wrote the book that comes back late most often?". The answers are spread across members, loans, copies, books and authors.
That spread is not a defect: it is exactly what we decided in lesson 02-01 when we separated the work (books) from the physical object (copies), and when we chose not to repeat the member's name in every loan. Information is stored once, in the place where it belongs, and reassembled when needed. The tool that reassembles it is the JOIN, relational algebra's join ⋈.
This is the lesson where SQL stops looking like a table browser and starts looking like a real query language. It is long and dense; take it slowly and run every example against your biblioredb.
Contents
- Why the data has to be reassembled
- Cartesian product and
CROSS JOIN INNER JOIN: the basic joinLEFT JOINand negative questionsRIGHT JOINandFULL OUTER JOIN- Visual summary of the
JOINtypes SELF JOIN: joining a table to itself- Chaining three or more tables
- Filtering in
ONor filtering inWHERE - Scalar subqueries
- Subqueries with
IN,EXISTSandNOT EXISTS - Correlated subqueries
- Derived tables: subqueries in
FROM - Common table expressions:
WITH - Set operators:
UNION,INTERSECT,EXCEPT - Common mistakes and tips
- Exercises
- Conclusion
- Why the data has to be reassembled
Look at the loans table raw:
| loan_id | member_id | copy_id | loan_date |
|---|---|---|---|
| 1 | 14 | 2 | 2026-03-02 |
| 2 | 15 | 1 | 2026-03-05 |
| 3 | 16 | 5 | 2026-03-11 |
To a human being this says nothing: 14, 2, 15, 1… are references. The member's name is in members, the copy's code in copies and the title in books. The alternative —storing the name and the title inside each loan— is precisely what the spreadsheet did, and we already saw the result: redundancy, inconsistency and typing errors.
The relational model's bargain is this: you store without repeating, and you pay for a JOIN when you query. It is an excellent bargain, because writing well happens once and reading badly happens forever.
- Cartesian product and
CROSS JOIN
CROSS JOINBefore pairing rows correctly, you have to understand what happens if you do not pair them. The Cartesian product × combines every row of one table with every row of the other.
SELECT br.name AS branch, a.last_name AS author
FROM branches br
CROSS JOIN authors a
ORDER BY br.branch_id, a.author_id;First rows:
| branch | author |
|---|---|
| Central | Palma |
| Central | Follett |
| Central | Valcárcel |
| Central | Barreda |
| … | … |
4 branches × 8 authors = 32 rows. With members (10) and books (9) it would be 90; with BiblioRed's real tables, 12,000 × 8,000 = 96 million.
Is it good for anything? Yes, in one specific case: generating every possible combination of two sets, for example to build a "each branch × each possible status" grid that is then filled in with data. Outside of that, a Cartesian product in production is almost always an accident.
The old form, and the classic accident
Before SQL-92 the word JOIN did not exist: tables were listed in the FROM separated by commas and the matching condition was written in the WHERE.
-- Old syntax: it works, but it is dangerous
SELECT l.loan_id, m.last_name
FROM loans l, members m
WHERE m.member_id = l.member_id;The danger is obvious: if you forget the WHERE condition, you get a Cartesian product in silence. Twelve loans times ten members are 120 rows that look like legitimate data. With large tables, the query hangs.
With the modern syntax, JOIN ... ON, the condition is glued to the join and cannot be lost from sight. Always use an explicit JOIN.
INNER JOIN: the basic join
INNER JOIN: the basic joinINNER JOIN is a Cartesian product followed by a filter: it returns only the pairs of rows that satisfy the condition. The word INNER is optional (a bare JOIN means INNER JOIN), but writing it makes clear that it is not a LEFT.
SELECT l.loan_id,
m.first_name || ' ' || m.last_name AS member,
l.loan_date
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
ORDER BY l.loan_id;| loan_id | member | loan_date |
|---|---|---|
| 1 | Marta Alsina | 2026-03-02 |
| 2 | Iván Pereda | 2026-03-05 |
| 3 | Nuria Bastos | 2026-03-11 |
| 4 | Marta Alsina | 2026-04-06 |
| 5 | Lucía Vendrell | 2026-04-12 |
| 6 | Nuria Bastos | 2026-05-04 |
| 7 | Álvaro Ferrán | 2026-05-08 |
| 8 | Sonia Quiroga | 2026-05-19 |
| 9 | Marta Alsina | 2026-07-14 |
| 10 | Iván Pereda | 2026-07-18 |
| 11 | Diego Salom | 2026-07-21 |
| 12 | Pau Miralles | 2026-07-25 |
Twelve rows: one per loan. Marta Alsina appears three times because she has three loans; that is not duplication, it is reality.
Table aliases
l and m are table aliases. They are not mandatory, but they are highly advisable:
- They shorten references:
m.last_nameinstead ofmembers.last_name. - They are indispensable when two tables have columns with the same name. If you write
SELECT member_id FROM loans JOIN members ON ..., the manager does not know which of the two you mean:
- They are mandatory in a
SELF JOIN(section 7).
Style advice: use recognizable initials (m members, l loans, c copies, b books, a authors, br branches) and be consistent across the whole project. In this course we will always use those same ones.
Combining JOIN with WHERE
SELECT m.member_id,
m.first_name || ' ' || m.last_name AS member,
l.due_date
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
WHERE l.return_date IS NULL
ORDER BY l.due_date;| member_id | member | due_date |
|---|---|---|
| 14 | Marta Alsina | 2026-08-04 |
| 15 | Iván Pereda | 2026-08-08 |
| 17 | Diego Salom | 2026-08-11 |
| 19 | Pau Miralles | 2026-08-15 |
The four members with something outstanding, with each one's deadline.
USING and NATURAL JOIN
When the columns in both tables are called exactly the same, there is a shorthand:
USING (member_id) is equivalent to ON m.member_id = l.member_id, and it also merges the two columns into one in the result. It is convenient and our schema allows it, because we named the foreign keys the same as the primary ones.
There is also NATURAL JOIN, which automatically matches on all columns whose names coincide:
Here copies and books share book_id… but if one day somebody adds a notes column to both, the NATURAL JOIN will start matching on that too and the query will change meaning without anyone having touched it. It is implicit magic: avoid it. Explicit ON or, at most, USING.
LEFT JOIN and negative questions
LEFT JOIN and negative questionsINNER JOIN throws away whatever does not match. Sometimes that is precisely what you do not want.
-- How many loans does each member have? With INNER JOIN, members
-- with no loans disappear from the listing.
SELECT m.member_id, m.last_name, l.loan_id
FROM members m
INNER JOIN loans l ON l.member_id = m.member_id;It returns 12 rows and only 8 members appear: Ramón Etxebarri (13) and Elena Roig (20) have vanished.
LEFT JOIN (short for LEFT OUTER JOIN) keeps all the rows of the left-hand table; when there is no partner on the right, it fills those columns with NULL.
SELECT m.member_id, m.last_name, l.loan_id, l.loan_date
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
ORDER BY m.member_id, l.loan_id;| member_id | last_name | loan_id | loan_date |
|---|---|---|---|
| 11 | Ferrán | 7 | 2026-05-08 |
| 12 | Quiroga | 8 | 2026-05-19 |
| 13 | Etxebarri | (NULL) | (NULL) |
| 14 | Alsina | 1 | 2026-03-02 |
| 14 | Alsina | 4 | 2026-04-06 |
| 14 | Alsina | 9 | 2026-07-14 |
| 15 | Pereda | 2 | 2026-03-05 |
| 15 | Pereda | 10 | 2026-07-18 |
| 16 | Bastos | 3 | 2026-03-11 |
| 16 | Bastos | 6 | 2026-05-04 |
| 17 | Salom | 11 | 2026-07-21 |
| 18 | Vendrell | 5 | 2026-04-12 |
| 19 | Miralles | 12 | 2026-07-25 |
| 20 | Roig | (NULL) | (NULL) |
14 rows: the 12 loans plus the two "empty" rows for Etxebarri and Roig.
The anti-join pattern: finding what does NOT exist
And here comes one of the most useful patterns in all of SQL. If the unmatched rows are the ones with NULL in the right-hand columns, it is enough to filter for them:
SELECT m.member_id, m.first_name, m.last_name, m.join_date
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
WHERE l.loan_id IS NULL
ORDER BY m.member_id;| member_id | first_name | last_name | join_date |
|---|---|---|---|
| 13 | Ramón | Etxebarri | 2020-11-14 |
| 20 | Elena | Roig | 2024-10-01 |
The two members who have never borrowed anything. It is relational algebra's difference −, implemented with a LEFT JOIN.
A crucial detail: the column you check with IS NULL must be one that can never be null in the right-hand table —the primary key is the safe choice—. If you checked WHERE l.return_date IS NULL you would get something completely different (the open loans, plus the members with no loans).
Another real BiblioRed case: the copies that have never been lent, candidates for weeding.
SELECT c.code, b.title, c.status, c.acquisition_date
FROM copies c
JOIN books b ON b.book_id = c.book_id
LEFT JOIN loans l ON l.copy_id = c.copy_id
WHERE l.loan_id IS NULL
ORDER BY c.code;| code | title | status | acquisition_date |
|---|---|---|---|
| EJ-3083 | The Map of Time | available | 2021-06-01 |
| EJ-3088 | Algebra for the Impatient | in_repair | 2020-01-15 |
| EJ-3091 | Winter of the Birds | available | 2022-04-27 |
| EJ-3093 | Urban Gardening Handbook | available | 2023-09-11 |
| EJ-3094 | Urban Gardening Handbook | withdrawn | 2023-09-11 |
| EJ-3095 | Ensanche Records (1904) | available | 2003-01-15 |
Six copies that have sat on the shelf without ever going out. Exactly the kind of report the spreadsheet could not produce.
RIGHT JOIN and FULL OUTER JOIN
RIGHT JOIN and FULL OUTER JOINRIGHT JOIN is the mirror image of LEFT JOIN: it keeps all the rows of the right-hand table.
SELECT a.last_name AS author, b.title
FROM books b
RIGHT JOIN authors a ON a.author_id = b.author_id
ORDER BY a.last_name, b.title;| author | title |
|---|---|
| Barreda | Algebra for the Impatient |
| Barreda | Urban Gardening Handbook |
| Escolá | (NULL) |
| Follett | The Pillars of the Earth |
| Lemos | Winter of the Birds |
| Ordóñez | Delta Trails |
| Palma | The Map of Time |
| Sorrentino | Ravenna Notebooks |
| Valcárcel | House of Tides |
Nine rows: the eight authors, with Óscar Barreda repeated for his two works and Marina Escolá showing NULL because she has none yet. Notice that "Ensanche Records (1904)" does not appear: it is the book with no cataloged author, and RIGHT JOIN keeps the authors, not the books.
Every RIGHT JOIN query can be rewritten as a LEFT JOIN by swapping the tables, and that is the form you will see in 95% of professional code:
SELECT a.last_name AS author, b.title
FROM authors a
LEFT JOIN books b ON b.author_id = a.author_id;Advice: stick with LEFT JOIN. Reading a long query in which some JOINs go left and others go right is needlessly hard.
FULL OUTER JOIN
It keeps the unmatched rows from both sides:
SELECT a.last_name AS author, b.title
FROM authors a
FULL OUTER JOIN books b ON b.author_id = a.author_id
ORDER BY a.last_name NULLS LAST, b.title;| author | title |
|---|---|
| Barreda | Algebra for the Impatient |
| Barreda | Urban Gardening Handbook |
| Escolá | (NULL) |
| Follett | The Pillars of the Earth |
| Lemos | Winter of the Birds |
| Ordóñez | Delta Trails |
| Palma | The Map of Time |
| Sorrentino | Ravenna Notebooks |
| Valcárcel | House of Tides |
| (NULL) | Ensanche Records (1904) |
Ten rows: both the author with no books and the book with no author show up. It is the audit query par excellence: it shows the two loose ends at once.
A warning about SQLite
RIGHT JOIN and FULL OUTER JOIN did not exist in SQLite until version 3.39 (June 2022). If your sqlite3 is older, those two queries will raise a syntax error. Check your version with SELECT sqlite_version();. Workarounds:
- Rewrite the
RIGHT JOINas aLEFT JOINwith the tables swapped (always possible). - Simulate the
FULL OUTER JOINwith twoLEFT JOINs combined withUNION(section 15).
LEFT JOIN and INNER JOIN, by contrast, have worked in SQLite forever.
- Visual summary of the
JOIN types
JOIN typesImagine two minimal tables matched on a key:
- LEFT with keys
1, 2, 3 - RIGHT with keys
2, 3, 4
JOIN type |
Rows it returns | Keys in the result | No. of rows |
|---|---|---|---|
INNER JOIN |
Only the ones that match | 2, 3 | 2 |
LEFT JOIN |
All the left-hand ones | 1 (with NULL), 2, 3 |
3 |
RIGHT JOIN |
All the right-hand ones | 2, 3, 4 (with NULL) |
3 |
FULL OUTER JOIN |
All from both sides | 1, 2, 3, 4 | 4 |
CROSS JOIN |
Every combination | 3 × 3 pairs | 9 |
LEFT JOIN + IS NULL |
Only the left-hand ones without a partner | 1 | 1 |
And the decision tree worth keeping in your head when writing a query:
flowchart TD
A["Which rows do I want in the result?"] --> B{"Do I need rows<br/>with no match?"}
B -->|No: only the matched ones| C["INNER JOIN"]
B -->|Yes| D{"From which side?"}
D -->|"Only from the main table<br/>(the one in the FROM)"| E["LEFT JOIN"]
D -->|Only from the secondary one| F["RIGHT JOIN<br/><i>better: turn it around<br/>and use LEFT JOIN</i>"]
D -->|From both sides| G["FULL OUTER JOIN"]
E --> H{"Do I want EXCLUSIVELY<br/>the ones that do not match?"}
H -->|Yes| I["LEFT JOIN + WHERE right_key IS NULL<br/><i>(anti-join)</i>"]
H -->|No| J["Plain LEFT JOIN"]
B -->|"I want every possible<br/>combination, unmatched"| K["CROSS JOIN"]
SELF JOIN: joining a table to itself
SELF JOIN: joining a table to itselfIt is not a different kind of JOIN: it is an ordinary INNER or LEFT JOIN in which both tables are the same one. It is used to compare rows of a table with each other, and that is why aliases are mandatory: you have to be able to tell the two "copies" apart.
Question: which pairs of members are signed up at the same branch?
SELECT m1.last_name AS member_a,
m2.last_name AS member_b,
m1.branch_id
FROM members m1
INNER JOIN members m2
ON m2.branch_id = m1.branch_id
AND m2.member_id > m1.member_id
ORDER BY m1.branch_id, m1.last_name, m2.last_name;| member_a | member_b | branch_id |
|---|---|---|
| Bastos | Roig | 1 |
| Etxebarri | Bastos | 1 |
| Etxebarri | Roig | 1 |
| Ferrán | Bastos | 1 |
| Ferrán | Etxebarri | 1 |
| Ferrán | Roig | 1 |
| Alsina | Miralles | 2 |
| Alsina | Pereda | 2 |
| Pereda | Miralles | 2 |
| Quiroga | Salom | 3 |
Ten pairs. The condition m2.member_id > m1.member_id does two things at once and is the trick to memorize:
- It stops each member from being paired with themselves (which would be
m1.member_id = m2.member_id). - It avoids the symmetric duplicate: if (Ferrán, Roig) comes out, (Roig, Ferrán) does not come out as well.
Without that condition you would get 4×4 + 3×3 + 2×2 + 1×1 = 30 rows instead of 10.
Another SELF JOIN that is useful in BiblioRed: other works by the same author.
SELECT b1.title AS book, b2.title AS other_work_by_same_author
FROM books b1
INNER JOIN books b2 ON b2.author_id = b1.author_id
AND b2.book_id <> b1.book_id
ORDER BY b1.title;| book | other_work_by_same_author |
|---|---|
| Algebra for the Impatient | Urban Gardening Handbook |
| Urban Gardening Handbook | Algebra for the Impatient |
Here we do want both directions (so we can recommend starting from either of the two books), which is why we use <> instead of >. Óscar Barreda is the only author with two works in the collection.
The canonical real-world use of the SELF JOIN is hierarchies: an employees table with a manager_id column that points back at the same table. BiblioRed has none, but the mechanism is identical.
- Chaining three or more tables
JOINs are chained top to bottom: the result of the first is joined with the next table, and so on. BiblioRed's star query walks through five tables.
flowchart LR
M["members"] --> L["loans"]
L --> C["copies"]
C --> B["books"]
B --> A["authors"]
SELECT m.first_name || ' ' || m.last_name AS member,
c.code AS copy,
b.title,
a.last_name AS author,
l.loan_date,
l.due_date
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
INNER JOIN copies c ON c.copy_id = l.copy_id
INNER JOIN books b ON b.book_id = c.book_id
LEFT JOIN authors a ON a.author_id = b.author_id
WHERE l.return_date IS NULL
ORDER BY l.loan_date;| member | copy | title | author | loan_date | due_date |
|---|---|---|---|---|---|
| Marta Alsina | EJ-3081 | The Map of Time | Palma | 2026-07-14 | 2026-08-04 |
| Iván Pereda | EJ-3084 | The Pillars of the Earth | Follett | 2026-07-18 | 2026-08-08 |
| Diego Salom | EJ-3086 | House of Tides | Valcárcel | 2026-07-21 | 2026-08-11 |
| Pau Miralles | EJ-3090 | Winter of the Birds | Lemos | 2026-07-25 | 2026-08-15 |
This is the listing the librarian wants to see every morning. And we can now answer the question we opened with: the person who has EJ-3081 right now is Marta Alsina, and she has to return it on 4 August 2026.
Three decisions in that query deserve comment:
- We start from
loans, not frommembers. When you chain several tables, starting at the "central" table —the one holding the foreign keys to the rest— makes the query far more natural to read. authorsis joined with aLEFT JOIN. Why? Becausebooks.author_idacceptsNULL(remember "Ensanche Records"). With anINNER JOIN, if somebody lent that copy, the loan would disappear from the morning report without anyone noticing. It is the most frequent silent error when chaining tables: anINNER JOINon an optional foreign key loses rows.- The order in which you write the
JOINs does not determine the execution order. The optimizer (lesson 01-04) decides on its own. You write for readability; it executes for speed.
- Filtering in
ON or filtering in WHERE
ON or filtering in WHEREThis distinction is subtle, it comes up in every interview and it produces incorrect results on a daily basis. With INNER JOIN it makes absolutely no difference where you put the condition. With LEFT JOIN, it changes the result completely.
The reason lies in the evaluation order:
ONis applied while the rows are being matched: it decides what is a match and what is not.WHEREis applied after the join has been built: it discards rows from the assembled result, including theNULL-filled rows that theLEFT JOINhad just preserved.
Question: "list of all members, showing which loans they have made from July 2026 onwards".
With the condition in ON
SELECT m.member_id, m.last_name, l.loan_id, l.loan_date
FROM members m
LEFT JOIN loans l
ON l.member_id = m.member_id
AND l.loan_date >= '2026-07-01'
ORDER BY m.member_id;| member_id | last_name | loan_id | loan_date |
|---|---|---|---|
| 11 | Ferrán | (NULL) | (NULL) |
| 12 | Quiroga | (NULL) | (NULL) |
| 13 | Etxebarri | (NULL) | (NULL) |
| 14 | Alsina | 9 | 2026-07-14 |
| 15 | Pereda | 10 | 2026-07-18 |
| 16 | Bastos | (NULL) | (NULL) |
| 17 | Salom | 11 | 2026-07-21 |
| 18 | Vendrell | (NULL) | (NULL) |
| 19 | Miralles | 12 | 2026-07-25 |
| 20 | Roig | (NULL) | (NULL) |
Ten rows: every member is there, which is what the question asked for. The ones with no July loans come out with NULL.
With the condition in WHERE
SELECT m.member_id, m.last_name, l.loan_id, l.loan_date
FROM members m
LEFT JOIN loans l ON l.member_id = m.member_id
WHERE l.loan_date >= '2026-07-01'
ORDER BY m.member_id;| member_id | last_name | loan_id | loan_date |
|---|---|---|---|
| 14 | Alsina | 9 | 2026-07-14 |
| 15 | Pereda | 10 | 2026-07-18 |
| 17 | Salom | 11 | 2026-07-21 |
| 19 | Miralles | 12 | 2026-07-25 |
Four rows. The WHERE has removed every row with l.loan_date at NULL, because NULL >= '2026-07-01' is UNKNOWN. The LEFT JOIN has effectively turned into an INNER JOIN.
| Where the condition goes | Effect on INNER JOIN |
Effect on LEFT JOIN |
|---|---|---|
In ON |
Identical | Filters what gets matched; keeps every left-hand row |
In WHERE |
Identical | Filters the result; cancels the effect of the LEFT |
Practical rule: in a LEFT JOIN, conditions on the right-hand table go in the ON; conditions on the left-hand table go in the WHERE. The only exception is the anti-join pattern WHERE right_key IS NULL, where you deliberately want that effect.
- Scalar subqueries
A subquery is a SELECT inside another SELECT, in parentheses. The simplest variety is the scalar one: it returns exactly one row and one column, that is, a single value, and can therefore be used anywhere a value fits.
In the WHERE
-- Books published after "The Map of Time"
SELECT title, publication_year
FROM books
WHERE publication_year > (SELECT publication_year FROM books WHERE book_id = 331)
ORDER BY publication_year;| title | publication_year |
|---|---|
| Ravenna Notebooks | 2012 |
| House of Tides | 2015 |
| Delta Trails | 2017 |
| Algebra for the Impatient | 2019 |
| Winter of the Birds | 2021 |
| Urban Gardening Handbook | 2023 |
The subquery runs once, returns 2008, and the outer query uses it as if you had typed it by hand. The advantage is that you do not have to know the value in advance: if that book's year is corrected tomorrow, the query is still correct.
If a scalar subquery returns more than one row, the manager raises an error:
SELECT title FROM books
WHERE publication_year > (SELECT publication_year FROM books WHERE publisher = 'Andana Press');If it returns zero rows, by contrast, there is no error: the result is NULL, the whole comparison becomes UNKNOWN, and the outer query returns zero rows… silently. Another application of three-valued logic.
In the SELECT list
SELECT c.code,
c.status,
(SELECT b.title FROM books b WHERE b.book_id = c.book_id) AS title
FROM copies c
WHERE c.branch_id = 4
ORDER BY c.code;| code | status | title |
|---|---|---|
| EJ-3088 | in_repair | Algebra for the Impatient |
| EJ-3092 | available | Delta Trails |
This is equivalent to a LEFT JOIN with books. As a general rule, prefer the JOIN: it is more readable, more flexible (you can bring several columns from the joined table) and the optimizer usually handles it better. A subquery in the SELECT is reserved for when you need a single computed value and the JOIN would complicate the query.
- Subqueries with
IN, EXISTS and NOT EXISTS
IN, EXISTS and NOT EXISTSWhen the subquery returns several rows, you cannot compare it with =, but you can ask about membership or about existence.
IN
-- Members who have an open loan
SELECT member_id, first_name, last_name
FROM members
WHERE member_id IN (SELECT member_id FROM loans WHERE return_date IS NULL)
ORDER BY member_id;| member_id | first_name | last_name |
|---|---|---|
| 14 | Marta | Alsina |
| 15 | Iván | Pereda |
| 17 | Diego | Salom |
| 19 | Pau | Miralles |
Compare it with the JOIN version. With a JOIN you would have to add DISTINCT so as not to repeat the members with several open loans; with IN you do not, because membership of a set is yes or no. That is its main advantage.
NOT IN and the NULL trap
-- Books nobody has ever reserved
SELECT book_id, title
FROM books
WHERE book_id NOT IN (SELECT book_id FROM reservations)
ORDER BY book_id;| book_id | title |
|---|---|
| 334 | Algebra for the Impatient |
| 335 | Ravenna Notebooks |
| 337 | Delta Trails |
| 338 | Urban Gardening Handbook |
| 339 | Ensanche Records (1904) |
It works because reservations.book_id is NOT NULL. Now the same idea on a column that does accept nulls:
-- Authors with no work in the collection
SELECT author_id, last_name
FROM authors
WHERE author_id NOT IN (SELECT author_id FROM books);Zero rows, when the correct answer is "Marina Escolá". We saw why in 02-01: the subquery returns {1, 2, 3, 4, 5, 6, 7, NULL} (the NULL is the one from "Ensanche Records"), and 8 NOT IN (..., NULL) translates into 8 <> 1 AND ... AND 8 <> NULL, whose last term is UNKNOWN. And TRUE AND UNKNOWN is UNKNOWN, which does not pass the filter. With a single NULL in the list, NOT IN never returns a single row.
The three solutions, from worst to best:
-- 1) Filter the NULLs by hand: it works, but you have to remember every time
SELECT author_id, last_name FROM authors
WHERE author_id NOT IN (SELECT author_id FROM books WHERE author_id IS NOT NULL);
-- 2) Anti-join with LEFT JOIN
SELECT a.author_id, a.last_name FROM authors a
LEFT JOIN books b ON b.author_id = a.author_id
WHERE b.book_id IS NULL;
-- 3) NOT EXISTS: immune to the problem by construction
SELECT a.author_id, a.last_name FROM authors a
WHERE NOT EXISTS (SELECT 1 FROM books b WHERE b.author_id = a.author_id);All three now return:
| author_id | last_name |
|---|---|
| 8 | Escolá |
EXISTS and NOT EXISTS
EXISTS does not compare values: it asks whether the subquery returns at least one row. It returns TRUE or FALSE, never UNKNOWN, and that is why it is immune to the previous trap.
-- Members with an open loan (the same question as with IN)
SELECT m.member_id, m.first_name, m.last_name
FROM members m
WHERE EXISTS (SELECT 1
FROM loans l
WHERE l.member_id = m.member_id
AND l.return_date IS NULL)
ORDER BY m.member_id;| member_id | first_name | last_name |
|---|---|---|
| 14 | Marta | Alsina |
| 15 | Iván | Pereda |
| 17 | Diego | Salom |
| 19 | Pau | Miralles |
The SELECT 1 is a convention: since all that matters is whether there are rows, not which rows, a constant is used. SELECT * would work just the same and the manager optimizes it identically.
-- Members who have never borrowed anything
SELECT m.member_id, m.first_name, m.last_name
FROM members m
WHERE NOT EXISTS (SELECT 1 FROM loans l WHERE l.member_id = m.member_id)
ORDER BY m.member_id;| member_id | first_name | last_name |
|---|---|---|
| 13 | Ramón | Etxebarri |
| 20 | Elena | Roig |
Same result as the anti-join in section 4. Three ways of expressing the same question:
| Form | Readability | Risk with NULL |
When to use it |
|---|---|---|---|
LEFT JOIN ... IS NULL |
Medium | None (if you check the primary key) | When you also need columns from the right-hand table |
NOT IN (subquery) |
High | High | Only if the column is NOT NULL |
NOT EXISTS |
High | None | The default choice |
- Correlated subqueries
The EXISTS subqueries you have just seen have a peculiarity: they mention a column from the outer query (m.member_id). That makes them correlated: they cannot be run on their own, because they depend on the row being evaluated at each moment.
| Independent subquery | Correlated subquery | |
|---|---|---|
| Can it be run separately? | Yes | No |
| How many times is it evaluated? | Once | Conceptually, once per outer row |
| Example | WHERE book_id IN (SELECT book_id FROM reservations) |
WHERE EXISTS (SELECT 1 FROM reservations r WHERE r.book_id = b.book_id) |
A BiblioRed example that answers a question that would be genuinely hard any other way: which copies belong to a book that has an active reservation? (they are the ones to set aside as soon as they come back to the front desk).
SELECT c.code, c.status, c.branch_id, b.title
FROM copies c
INNER JOIN books b ON b.book_id = c.book_id
WHERE EXISTS (SELECT 1
FROM reservations r
WHERE r.book_id = c.book_id
AND r.status = 'active')
ORDER BY c.code;| code | status | branch_id | title |
|---|---|---|---|
| EJ-3081 | on_loan | 2 | The Map of Time |
| EJ-3082 | available | 1 | The Map of Time |
| EJ-3083 | available | 3 | The Map of Time |
| EJ-3084 | on_loan | 1 | The Pillars of the Earth |
| EJ-3085 | available | 2 | The Pillars of the Earth |
Five copies on alert: the three of "The Map of Time" (two active reservations) and the two of "The Pillars of the Earth" (one).
About performance: the description "it is evaluated once per outer row" is the mental model, not necessarily what happens. Modern optimizers usually transform a correlated subquery into a JOIN internally. Even so, on large tables it is worth measuring; in lesson 06-03 you will learn to check it with EXPLAIN.
- Derived tables: subqueries in
FROM
FROMA subquery can also take the place of a table in the FROM. It is called a derived table and, like any table, it needs an alias.
SELECT br.name AS branch,
ol.code,
ol.loan_date
FROM (SELECT l.loan_id, l.loan_date, c.code, c.branch_id
FROM loans l
INNER JOIN copies c ON c.copy_id = l.copy_id
WHERE l.return_date IS NULL) AS ol
INNER JOIN branches br ON br.branch_id = ol.branch_id
ORDER BY br.name, ol.loan_date;| branch | code | loan_date |
|---|---|---|
| Central | EJ-3084 | 2026-07-18 |
| Central | EJ-3086 | 2026-07-21 |
| North | EJ-3081 | 2026-07-14 |
| North | EJ-3090 | 2026-07-25 |
The four open loans, split across the branches where each copy lives. The derived table ol (for "open loans") exists only for the duration of the query.
Derived tables solve cases where you need to work on an intermediate result, above all when that intermediate includes aggregates —something we will see in lesson 02-05—. Their drawback is readability: if you nest two or three, the query becomes a labyrinth of parentheses that has to be read from the inside out.
- Common table expressions:
WITH
WITHA CTE (common table expression) is a derived table that is given a name before it is used. Same power, incomparably better readability.
WITH open_loans AS (
SELECT l.loan_id, l.loan_date, c.code, c.branch_id
FROM loans l
INNER JOIN copies c ON c.copy_id = l.copy_id
WHERE l.return_date IS NULL
)
SELECT br.name AS branch,
ol.code,
ol.loan_date
FROM open_loans ol
INNER JOIN branches br ON br.branch_id = ol.branch_id
ORDER BY br.name, ol.loan_date;It returns exactly the same as the previous section, but now the query reads from top to bottom, like a procedure: "first I work out the open ones, then I cross them with the branches".
Several chained CTEs
They are separated by commas, and each one can use the previous ones:
WITH open_loans AS (
SELECT l.loan_id, l.member_id, l.loan_date, c.code
FROM loans l
INNER JOIN copies c ON c.copy_id = l.copy_id
WHERE l.return_date IS NULL
),
north_members AS (
SELECT member_id, first_name, last_name
FROM members
WHERE branch_id = 2
)
SELECT nm.first_name || ' ' || nm.last_name AS member,
ol.code,
ol.loan_date
FROM north_members nm
INNER JOIN open_loans ol ON ol.member_id = nm.member_id
ORDER BY ol.loan_date;| member | code | loan_date |
|---|---|---|
| Marta Alsina | EJ-3081 | 2026-07-14 |
| Iván Pereda | EJ-3084 | 2026-07-18 |
| Pau Miralles | EJ-3090 | 2026-07-25 |
Three of the four open loans belong to members of the North branch; the fourth is Diego Salom's, who signed up at South.
Advantages of CTEs:
- Readability: each block has a name and a purpose.
- Reuse: a CTE can be referenced several times in the main query, whereas a derived table would have to be written twice.
- Debugging: you can run the CTE's content separately to see what it produces.
Dialect notes:
WITHis standard and works in PostgreSQL (since 8.4) and SQLite (since 3.8.3).- There is
WITH RECURSIVEfor walking hierarchies and graphs (category trees, bills of materials). It is powerful and falls outside the scope of this introductory course. - Historically PostgreSQL treated every CTE as an optimization fence; since version 12 it folds them into the main query unless you write
MATERIALIZED. If you work with an earlier version and notice slowness, that may be the cause.
- Set operators:
UNION, INTERSECT, EXCEPT
UNION, INTERSECT, EXCEPTJOINs combine tables sideways (they add columns). Set operators combine them vertically (they stack rows). They are relational algebra's union ∪, intersection ∩ and difference −.
To use them, the two queries must be compatible: same number of columns and compatible types, in the same order. The column names come from the first query.
UNION and UNION ALL
-- Members "in motion": with an open loan or an active reservation
SELECT member_id FROM loans WHERE return_date IS NULL
UNION
SELECT member_id FROM reservations WHERE status = 'active'
ORDER BY member_id;| member_id |
|---|
| 14 |
| 15 |
| 16 |
| 17 |
| 18 |
| 19 |
Six members. The first query returns {14, 15, 17, 19} and the second {16, 18, 15}; Iván Pereda (15) is in both and appears only once, because UNION removes duplicates.
SELECT member_id FROM loans WHERE return_date IS NULL
UNION ALL
SELECT member_id FROM reservations WHERE status = 'active'
ORDER BY member_id;| member_id |
|---|
| 14 |
| 15 |
| 15 |
| 16 |
| 17 |
| 18 |
| 19 |
Seven rows: UNION ALL does not remove duplicates. And precisely for that reason it is faster: it does not have to sort or compare anything. Practical rule: use UNION ALL unless you need the deduplication. Plenty of people write UNION out of habit and pay the cost for nothing.
INTERSECT
-- Members who have an open loan AND ALSO an active reservation
SELECT member_id FROM loans WHERE return_date IS NULL
INTERSECT
SELECT member_id FROM reservations WHERE status = 'active';| member_id |
|---|
| 15 |
Iván Pereda: he has "The Pillars of the Earth" on loan and "The Map of Time" reserved.
EXCEPT
-- Members with an open loan but WITHOUT any active reservation
SELECT member_id FROM loans WHERE return_date IS NULL
EXCEPT
SELECT member_id FROM reservations WHERE status = 'active'
ORDER BY member_id;| member_id |
|---|
| 14 |
| 17 |
| 19 |
EXCEPT is not symmetric: A EXCEPT B is not the same as B EXCEPT A. If you swapped the order you would get {16, 18}, the members with an active reservation and no open loans.
Details to keep in mind
INTERSECTandEXCEPTalso remove duplicates by default;INTERSECT ALLandEXCEPT ALLexist.- The
ORDER BYgoes at the end, once only, and sorts the combined result. You cannot put one in each branch (except in parentheses withLIMIT). - In Oracle,
EXCEPTis calledMINUS. - SQLite has supported
UNION,UNION ALL,INTERSECTandEXCEPTforever; it is its strong point compared with outer joins.
A very practical use in old SQLite: simulating a FULL OUTER JOIN.
SELECT a.last_name, b.title FROM authors a LEFT JOIN books b ON b.author_id = a.author_id
UNION
SELECT a.last_name, b.title FROM books b LEFT JOIN authors a ON a.author_id = b.author_id;The ten rows from section 5, without needing FULL OUTER JOIN.
Common Mistakes and Tips
- Forgetting the join condition. With the old comma syntax it produces a silent Cartesian product. Always use
JOIN ... ON. - Using
INNER JOINon a foreign key that acceptsNULL. You lose rows with no warning. If the column can be null,LEFT JOIN. - Putting the right-hand table's condition in the
WHEREof aLEFT JOIN. It turns it into anINNER JOINand you lose the rows you wanted to keep. It goes in theON. - Checking
IS NULLon the wrong column in an anti-join. Always use the right-hand table's primary key, which can never be null on its own. NOT INwith a subquery that can returnNULL. Zero rows, always, silently. UseNOT EXISTS.- Mistaking "more rows than expected" for a
JOINbug. If a member has three loans, they will appear three times: that is correct. The problem arises when counting (COUNT) over that result, and we will see it in lesson 02-05. - Chaining
JOINs without aliases. With five tables and columns sharing names, a query with no aliases is unreadable and even ambiguous to the manager. - Using
NATURAL JOIN. It matches on coinciding names and changes meaning when somebody adds a column. - Nesting derived tables three levels deep. Turn them into CTEs with
WITH: same result, half the time to understand it. - Tip: when a multi-table query returns something odd, strip clauses away until it works. Run the bare
JOINfirst withSELECT *, see how many rows come out and add conditions one at a time. - Tip: always write the join condition in the order
ON new_table.column = table_already_present.column. It is a minor convention, but when reading a five-table query it is enormously appreciated.
Exercises
Exercise 1: Basic joins
- List the copies with the title of the book they belong to and the name of their branch. Sort by branch and code.
- Show the active reservations with the member's name and the title of the reserved book.
- List all the books with their author's last name, including the ones with no cataloged author.
- Show the loans returned late, giving member, title and both dates.
Exercise 2: Negative questions
- Which books have no copy at the Central branch (
branch_id = 1)? Solve it withNOT EXISTS. - Which members have never made a single reservation? Solve it two ways: with an anti-join and with
NOT EXISTS. - Which branches have no copy with status
on_loan? - Which authors have work in the collection but none of their works has ever been lent?
Exercise 3: Compound queries
- Using a CTE, get the available copies of books that have an active reservation, with their code, title and branch. It is the "set aside for reservations" list.
- With set operators, get the identifiers of the members who have made a reservation but never a loan.
- List, for each branch, the members signed up there and the copies it holds… and explain why this should not be done with a single three-table
JOIN.
Solutions
Solution 1
-- 1
SELECT br.name AS branch, c.code, b.title, c.status
FROM copies c
INNER JOIN books b ON b.book_id = c.book_id
INNER JOIN branches br ON br.branch_id = c.branch_id
ORDER BY br.name, c.code;Fifteen rows. The first two and the last two:
| branch | code | title | status |
|---|---|---|---|
| Central | EJ-3082 | The Map of Time | available |
| Central | EJ-3084 | The Pillars of the Earth | on_loan |
| … | … | … | … |
| South | EJ-3089 | Ravenna Notebooks | available |
| South | EJ-3094 | Urban Gardening Handbook | withdrawn |
-- 2
SELECT m.first_name || ' ' || m.last_name AS member, b.title, r.reservation_date
FROM reservations r
INNER JOIN members m ON m.member_id = r.member_id
INNER JOIN books b ON b.book_id = r.book_id
WHERE r.status = 'active'
ORDER BY r.reservation_date;| member | title | reservation_date |
|---|---|---|
| Nuria Bastos | The Map of Time | 2026-07-20 |
| Lucía Vendrell | The Pillars of the Earth | 2026-07-22 |
| Iván Pereda | The Map of Time | 2026-07-28 |
-- 3 LEFT JOIN, because books.author_id accepts NULL
SELECT b.title, a.last_name AS author
FROM books b
LEFT JOIN authors a ON a.author_id = b.author_id
ORDER BY b.title;| title | author |
|---|---|
| Algebra for the Impatient | Barreda |
| Delta Trails | Ordóñez |
| Ensanche Records (1904) | (NULL) |
| House of Tides | Valcárcel |
| Ravenna Notebooks | Sorrentino |
| The Map of Time | Palma |
| The Pillars of the Earth | Follett |
| Urban Gardening Handbook | Barreda |
| Winter of the Birds | Lemos |
With an INNER JOIN you would have lost "Ensanche Records (1904)".
-- 4
SELECT m.first_name || ' ' || m.last_name AS member,
b.title,
l.due_date,
l.return_date,
l.surcharge
FROM loans l
INNER JOIN members m ON m.member_id = l.member_id
INNER JOIN copies c ON c.copy_id = l.copy_id
INNER JOIN books b ON b.book_id = c.book_id
WHERE l.return_date > l.due_date
ORDER BY l.loan_date;| member | title | due_date | return_date | surcharge |
|---|---|---|---|---|
| Iván Pereda | The Map of Time | 2026-03-26 | 2026-04-02 | 1.40 |
| Lucía Vendrell | Ravenna Notebooks | 2026-05-03 | 2026-05-10 | 1.40 |
| Sonia Quiroga | Delta Trails | 2026-06-09 | 2026-06-30 | 4.20 |
Solution 2
-- 1
SELECT b.book_id, b.title
FROM books b
WHERE NOT EXISTS (SELECT 1 FROM copies c
WHERE c.book_id = b.book_id AND c.branch_id = 1)
ORDER BY b.book_id;| book_id | title |
|---|---|
| 334 | Algebra for the Impatient |
| 335 | Ravenna Notebooks |
| 337 | Delta Trails |
-- 2a Anti-join
SELECT m.member_id, m.last_name
FROM members m
LEFT JOIN reservations r ON r.member_id = m.member_id
WHERE r.reservation_id IS NULL
ORDER BY m.member_id;
-- 2b NOT EXISTS
SELECT m.member_id, m.last_name
FROM members m
WHERE NOT EXISTS (SELECT 1 FROM reservations r WHERE r.member_id = m.member_id)
ORDER BY m.member_id;| member_id | last_name |
|---|---|
| 12 | Quiroga |
| 13 | Etxebarri |
| 17 | Salom |
| 19 | Miralles |
| 20 | Roig |
Members 11, 14, 15, 16 and 18 have reserved at some point; the other five, never.
-- 3
SELECT br.branch_id, br.name
FROM branches br
WHERE NOT EXISTS (SELECT 1 FROM copies c
WHERE c.branch_id = br.branch_id AND c.status = 'on_loan')
ORDER BY br.branch_id;| branch_id | name |
|---|---|
| 3 | South |
| 4 | East |
-- 4 Double negative: authors WITH books but WITHOUT loans of those books
SELECT a.author_id, a.last_name
FROM authors a
WHERE EXISTS (SELECT 1 FROM books b WHERE b.author_id = a.author_id)
AND NOT EXISTS (SELECT 1
FROM books b
INNER JOIN copies c ON c.book_id = b.book_id
INNER JOIN loans l ON l.copy_id = c.copy_id
WHERE b.author_id = a.author_id)
ORDER BY a.author_id;No author satisfies both conditions. Óscar Barreda (4) looked like a candidate, because "Urban Gardening Handbook" has never been lent, but his other work, "Algebra for the Impatient", has (loan 4). It is a good reminder that in a condition like "none of their works" you have to examine all of the author's works, not one at a time.
Solution 3
-- 1
WITH reserved AS (
SELECT DISTINCT book_id FROM reservations WHERE status = 'active'
)
SELECT c.code, b.title, br.name AS branch
FROM copies c
INNER JOIN reserved rv ON rv.book_id = c.book_id
INNER JOIN books b ON b.book_id = c.book_id
INNER JOIN branches br ON br.branch_id = c.branch_id
WHERE c.status = 'available'
ORDER BY c.code;| code | title | branch |
|---|---|---|
| EJ-3082 | The Map of Time | Central |
| EJ-3083 | The Map of Time | South |
| EJ-3085 | The Pillars of the Earth | North |
Three copies the staff must set aside. The DISTINCT in the CTE matters: "The Map of Time" has two active reservations, and without it each of its copies would appear twice.
The five members who have reserved at some point (11, 14, 15, 16, 18) have all made a loan too. Try the inverse operation to see the difference:
| member_id |
|---|
| 12 |
| 17 |
| 19 |
-- 3 The "naive" query
SELECT br.name, m.last_name, c.code
FROM branches br
LEFT JOIN members m ON m.branch_id = br.branch_id
LEFT JOIN copies c ON c.branch_id = br.branch_id;Why it must not be done this way: members and copies are not related to each other; both hang off branches independently. Joining them in the same query produces a Cartesian product within each branch: the Central branch has 4 members and 6 copies, so it generates 24 rows. The total is 4×6 + 3×4 + 2×3 + 1×2 = 24 + 12 + 6 + 2 = 44 rows, none of which means anything: they pair Nuria Bastos with a copy she has never touched.
This phenomenon has a name —row explosion or fan trap— and it is one of the most frequent causes of inflated counts. The two correct solutions:
-- a) Two separate queries, which is what the question really asked for
SELECT br.name, m.last_name FROM branches br
LEFT JOIN members m ON m.branch_id = br.branch_id ORDER BY br.name;
SELECT br.name, c.code FROM branches br
LEFT JOIN copies c ON c.branch_id = br.branch_id ORDER BY br.name;-- b) A single query, summarizing each branch separately before joining them.
-- This needs aggregate functions: it is exactly the subject
-- of the next lesson, 02-05.Conclusion
This lesson has turned BiblioRed's seven isolated tables into a queryable system:
- The data is spread on purpose —each fact in one place only— and reassembled at query time with relational algebra's join ⋈.
- The Cartesian product (
CROSS JOIN) is the conceptual starting point and the classic accident of the old comma syntax. INNER JOIN ... ONreturns only what matches; table aliases are all but mandatory andNATURAL JOINis magic best avoided.LEFT JOINkeeps the left-hand table, and its combination withWHERE right_key IS NULL—the anti-join— is the pattern for answering every question that starts with "the ones that never…": members with no loans, copies never lent.RIGHT JOINandFULL OUTER JOINcomplete the picture (and arrived late in SQLite, in version 3.39).- The
SELF JOINcompares rows of a table with itself, with the>condition so as not to duplicate symmetric pairs. - Chaining five tables —member → loan → copy → book → author— answers the front desk's real questions, provided you use
LEFT JOINwhere the foreign key accepts nulls. - Filtering in
ONis not the same as filtering inWHEREin aLEFT JOIN: theWHEREdegrades it to anINNER JOIN. - Subqueries in their five forms: scalar, with
IN, withEXISTS/NOT EXISTS, correlated and as a derived table in theFROM. With one rule burned in:NOT EXISTSinstead ofNOT INwhenever there may beNULLs. - CTEs with
WITH, which turn a labyrinthine query into a procedure that reads top to bottom. - The set operators:
UNION(deduplicates),UNION ALL(faster),INTERSECTandEXCEPT(which is not symmetric).
Notice that we have repeatedly brushed against a limit: we can list each member's loans, but not count them; we can see each branch's copies, but not how many there are; exercise 3.3 was left half-finished because summarizing two branches required something we do not have yet.
That something arrives in lesson 02-05, Data Aggregation and Grouping: COUNT, SUM, AVG, MIN and MAX, the GROUP BY clause, the difference between HAVING and WHERE, the logical order in which a query is really executed and —very important after what we have just seen— the problem of counting rows inflated by a JOIN. With it, BiblioRed will move from answering "what is there" to answering "how much is there".
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
