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

  1. Why the data has to be reassembled
  2. Cartesian product and CROSS JOIN
  3. INNER JOIN: the basic join
  4. LEFT JOIN and negative questions
  5. RIGHT JOIN and FULL OUTER JOIN
  6. Visual summary of the JOIN types
  7. SELF JOIN: joining a table to itself
  8. Chaining three or more tables
  9. Filtering in ON or filtering in WHERE
  10. Scalar subqueries
  11. Subqueries with IN, EXISTS and NOT EXISTS
  12. Correlated subqueries
  13. Derived tables: subqueries in FROM
  14. Common table expressions: WITH
  15. Set operators: UNION, INTERSECT, EXCEPT
  16. Common mistakes and tips
  17. Exercises
  18. Conclusion

  1. Why the data has to be reassembled

Look at the loans table raw:

SELECT loan_id, member_id, copy_id, loan_date FROM loans LIMIT 3;
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.

  1. Cartesian product and CROSS JOIN

Before 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
SELECT COUNT(*) FROM branches CROSS JOIN authors;
 count
-------
    32

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.

-- The slip!
SELECT l.loan_id, m.last_name FROM loans l, members m;   -- 120 rows

With the modern syntax, JOIN ... ON, the condition is glued to the join and cannot be lost from sight. Always use an explicit JOIN.

  1. INNER JOIN: the basic join

SELECT columns
FROM table_a
INNER JOIN table_b ON matching_condition;

INNER 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_name instead of members.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:
ERROR:  column reference "member_id" is ambiguous
  • 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:

SELECT l.loan_id, m.last_name
FROM loans l
INNER JOIN members m USING (member_id);

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:

-- Do NOT use it
SELECT * FROM copies NATURAL JOIN books;

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.

  1. LEFT JOIN and negative questions

INNER 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.

  1. RIGHT JOIN and FULL OUTER JOIN

RIGHT 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 JOIN as a LEFT JOIN with the tables swapped (always possible).
  • Simulate the FULL OUTER JOIN with two LEFT JOINs combined with UNION (section 15).

LEFT JOIN and INNER JOIN, by contrast, have worked in SQLite forever.

  1. Visual summary of the JOIN types

Imagine 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"]

  1. SELF JOIN: joining a table to itself

It 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:

  1. It stops each member from being paired with themselves (which would be m1.member_id = m2.member_id).
  2. 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.

  1. 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 from members. 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.
  • authors is joined with a LEFT JOIN. Why? Because books.author_id accepts NULL (remember "Ensanche Records"). With an INNER 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: an INNER JOIN on 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.

  1. Filtering in ON or filtering in WHERE

This 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:

  1. ON is applied while the rows are being matched: it decides what is a match and what is not.
  2. WHERE is applied after the join has been built: it discards rows from the assembled result, including the NULL-filled rows that the LEFT JOIN had 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.

  1. 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');
ERROR:  more than one row returned by a subquery used as an expression

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.

  1. Subqueries with IN, EXISTS and NOT EXISTS

When 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);
(0 rows)

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

  1. 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.

  1. Derived tables: subqueries in FROM

A 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.

  1. Common table expressions: WITH

A 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:

  • WITH is standard and works in PostgreSQL (since 8.4) and SQLite (since 3.8.3).
  • There is WITH RECURSIVE for 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.

  1. Set operators: UNION, INTERSECT, EXCEPT

JOINs 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

  • INTERSECT and EXCEPT also remove duplicates by default; INTERSECT ALL and EXCEPT ALL exist.
  • The ORDER BY goes at the end, once only, and sorts the combined result. You cannot put one in each branch (except in parentheses with LIMIT).
  • In Oracle, EXCEPT is called MINUS.
  • SQLite has supported UNION, UNION ALL, INTERSECT and EXCEPT forever; 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 JOIN on a foreign key that accepts NULL. You lose rows with no warning. If the column can be null, LEFT JOIN.
  • Putting the right-hand table's condition in the WHERE of a LEFT JOIN. It turns it into an INNER JOIN and you lose the rows you wanted to keep. It goes in the ON.
  • Checking IS NULL on 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 IN with a subquery that can return NULL. Zero rows, always, silently. Use NOT EXISTS.
  • Mistaking "more rows than expected" for a JOIN bug. 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 JOIN first with SELECT *, 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

  1. List the copies with the title of the book they belong to and the name of their branch. Sort by branch and code.
  2. Show the active reservations with the member's name and the title of the reserved book.
  3. List all the books with their author's last name, including the ones with no cataloged author.
  4. Show the loans returned late, giving member, title and both dates.

Exercise 2: Negative questions

  1. Which books have no copy at the Central branch (branch_id = 1)? Solve it with NOT EXISTS.
  2. Which members have never made a single reservation? Solve it two ways: with an anti-join and with NOT EXISTS.
  3. Which branches have no copy with status on_loan?
  4. Which authors have work in the collection but none of their works has ever been lent?

Exercise 3: Compound queries

  1. 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.
  2. With set operators, get the identifiers of the members who have made a reservation but never a loan.
  3. 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;
(0 rows)

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.

-- 2
SELECT member_id FROM reservations
EXCEPT
SELECT member_id FROM loans
ORDER BY member_id;
(0 rows)

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:

SELECT member_id FROM loans
EXCEPT
SELECT member_id FROM reservations
ORDER BY member_id;
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 ... ON returns only what matches; table aliases are all but mandatory and NATURAL JOIN is magic best avoided.
  • LEFT JOIN keeps the left-hand table, and its combination with WHERE 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 JOIN and FULL OUTER JOIN complete the picture (and arrived late in SQLite, in version 3.39).
  • The SELF JOIN compares 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 JOIN where the foreign key accepts nulls.
  • Filtering in ON is not the same as filtering in WHERE in a LEFT JOIN: the WHERE degrades it to an INNER JOIN.
  • Subqueries in their five forms: scalar, with IN, with EXISTS/NOT EXISTS, correlated and as a derived table in the FROM. With one rule burned in: NOT EXISTS instead of NOT IN whenever there may be NULLs.
  • CTEs with WITH, which turn a labyrinthine query into a procedure that reads top to bottom.
  • The set operators: UNION (deduplicates), UNION ALL (faster), INTERSECT and EXCEPT (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".

© Copyright 2026. All rights reserved