Combining Tables with JOIN
A loan slip says "book 1, member 2." To know it means Noli Me Tangere lent to Ben, you look up two other drawers and put the answers side by side.
In Databases, Tables, and SQL, you saw why the loans table stores numbers like book_id = 1 and member_id = 2 instead of copying out titles and names: each fact lives in exactly one place. That keeps the data correct, but it makes the loans table hard for people to read.
A join brings the information back together. It combines rows from two or more tables into one result, matching them up by their keys.
Matching up the cards
Imagine a loan card that says "book 1, member 2." To make sense of it, you walk to the books drawer and find card 1: Noli Me Tangere. Then you walk to the members drawer and find card 2: Ben Cruz. You lay the three cards side by side, and now you can read the whole story.
A join does exactly that, for every loan at once.
Your first join
SELECT loans.id, books.title, loans.due_date
FROM loans
JOIN books ON loans.book_id = books.id;+----+-----------------------------+------------+
| id | title | due_date |
+----+-----------------------------+------------+
| 1 | Noli Me Tangere | 2026-09-15 |
| 2 | Noli Me Tangere | 2026-09-26 |
| 3 | The Little Prince | 2026-09-29 |
| 4 | A Brief History of Time | 2026-09-03 |
| 5 | A Brief History of Time | 2026-09-24 |
| 6 | Smaller and Smaller Circles | 2026-10-04 |
| 7 | El Filibusterismo | 2026-09-15 |
+----+-----------------------------+------------+Reading it piece by piece:
FROM loans JOIN bookssays "combine the loans table with the books table."ON loans.book_id = books.idis the join condition: it says how the rows match up. A loan row goes with the book row whoseidequals the loan'sbook_id.- Because both tables have a column called
id, each column is written with its table's name in front,loans.idandbooks.id, so there's no confusion about which one you mean.
For each loan, the database finds the matching book and puts them together into one wider row. Then SELECT picks which columns to show.
Table aliases
Writing out loans. and books. everywhere gets long. You can give each table a short nickname, called an alias, right after its name in FROM and JOIN:
SELECT l.id, b.title, l.due_date
FROM loans l
JOIN books b ON l.book_id = b.id
WHERE l.returned_date IS NULL;+----+-----------------------------+------------+
| id | title | due_date |
+----+-----------------------------+------------+
| 2 | Noli Me Tangere | 2026-09-26 |
| 3 | The Little Prince | 2026-09-29 |
| 5 | A Brief History of Time | 2026-09-24 |
| 6 | Smaller and Smaller Circles | 2026-10-04 |
+----+-----------------------------+------------+loans l means "call the loans table l in this query." Short aliases like this are extremely common in real SQL. This query also shows that everything you already know still works with joins: here, WHERE keeps only the loans still out.
If you leave off the table name on a column that exists in both tables, SQLite can't tell which one you mean:
SELECT id FROM loans l JOIN books b ON l.book_id = b.id; -- error: ambiguous column name: idJoining three tables
To put the book title and the member's name next to each loan, join both tables. Each JOIN adds one more drawer:
SELECT m.name, b.title, l.due_date
FROM loans l
JOIN books b ON l.book_id = b.id
JOIN members m ON l.member_id = m.id
WHERE l.returned_date IS NULL
ORDER BY l.due_date;+--------------+-----------------------------+------------+
| name | title | due_date |
+--------------+-----------------------------+------------+
| Carlo Reyes | A Brief History of Time | 2026-09-24 |
| Ben Cruz | Noli Me Tangere | 2026-09-26 |
| Maria Santos | The Little Prince | 2026-09-29 |
| Ben Cruz | Smaller and Smaller Circles | 2026-10-04 |
+--------------+-----------------------------+------------+Now the loans table reads like a sentence: who has which book, and when it's due. This query is exactly what a real library system would run to show its "books currently out" screen.
Joins with GROUP BY
Remember the loans-per-member count from Grouping with GROUP BY and HAVING, which only showed member numbers? Add a join, and the names appear:
SELECT m.name, COUNT(*) AS loans
FROM loans l
JOIN members m ON l.member_id = m.id
GROUP BY m.id, m.name
ORDER BY loans DESC, m.name;+--------------+-------+
| name | loans |
+--------------+-------+
| Ben Cruz | 2 |
| Carlo Reyes | 2 |
| Maria Santos | 2 |
| Dina Lim | 1 |
+--------------+-------+The join happens first, then the grouping. Grouping by m.id as well as m.name makes sure two members who happen to share a name would still be counted separately.
Who's missing? LEFT JOIN
Look closely at that last result. There are five members, but only four appear. Eli Tan has never borrowed a book, so there's no loan row to match with him, and a normal join leaves him out.
A normal JOIN (also called an inner join, and you can write it as INNER JOIN) only keeps rows that have a match on both sides. When you want to keep every row from the first table, even the ones with no match, use a LEFT JOIN:
SELECT m.name, COUNT(l.id) AS loans
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
GROUP BY m.id, m.name
ORDER BY loans DESC, m.name;+--------------+-------+
| name | loans |
+--------------+-------+
| Ben Cruz | 2 |
| Carlo Reyes | 2 |
| Maria Santos | 2 |
| Dina Lim | 1 |
| Eli Tan | 0 |
+--------------+-------+Two changes made this work:
membersis now the first table, the left one, since it's the one we want every row of.LEFT JOIN loanskeeps every member, matched to their loans if they have any.- For a member with no loans, the loan columns are filled with
NULL.COUNT(l.id)skipsNULLs (as you saw in Aggregates), so Eli gets0.COUNT(*)would have counted his one row and given him1.
Finding what has no match
LEFT JOIN plus IS NULL is a handy way to find things that have no match at all. Which books have never been borrowed?
SELECT b.title
FROM books b
LEFT JOIN loans l ON l.book_id = b.id
WHERE l.id IS NULL;+--------+
| title |
+--------+
| Cosmos |
+--------+Every book is kept by the LEFT JOIN, and books with no loans get NULL in every loan column. Then WHERE l.id IS NULL keeps only those.
Other kinds of joins
You'll meet a few other join types in other people's SQL:
RIGHT JOINis aLEFT JOINwith the tables the other way around. Most people just swap the tables and useLEFT JOIN, which reads more naturally.FULL OUTER JOINkeeps unmatched rows from both sides.CROSS JOINpairs every row of one table with every row of the other, with no condition. Six books crossed with five members makes 30 rows. It's occasionally useful, but a join where you forget theONcondition can do this by accident, and on big tables, the result gets enormous.
SQLite has supported RIGHT and FULL OUTER JOIN since version 3.39, from 2022, so any recent version has them. INNER JOIN and LEFT JOIN cover the vast majority of real queries.
Try it
The editor creates small students, clubs, and memberships tables, then runs three joins. Predict each result, row by row.
The memberships table connects the other two: each row says "this student is in this club." A table like that, which links two others, is how databases store many-to-many relationships, where a student can join many clubs and a club can have many students.
Running SQL here
Running SQL right in the browser is coming to Syntaxia soon. For now, you can read and edit the queries here. To run them yourself, open the shell with sqlite3, type .mode table, and paste them in.
Check your prediction
+-------+----------+
| name | club |
+-------+----------+
| Ana | Chess |
| Ana | Robotics |
| Ben | Chess |
| Carlo | Robotics |
+-------+----------+
+-------+-------+
| name | clubs |
+-------+-------+
| Ana | 2 |
| Ben | 1 |
| Carlo | 1 |
| Dina | 0 |
+-------+-------+
+-------+
| club |
+-------+
| Drama |
+-------+Query 1 uses the link table twice over: once to find each membership's student, once for its club. Ana appears twice, since she's in two clubs, and Dina not at all. Query 2's LEFT JOIN keeps Dina, with 0 clubs. And Query 3 finds Drama, the only club with no matching membership.
Try it yourself
- In your library database, show every loan with the member's name and the book's title, including returned ones, sorted by loan date.
- Show each book's title with how many times it has been borrowed, including books that have never been borrowed.
- Find the names of members who have never borrowed a book.
Check your understanding
Up next
Some questions need an answer to another question first: which books are newer than the average book? Which members borrowed the most popular book? For those, you can put one query inside another, in Subqueries.
