Lesson 07 / 18
Inner Join
Gathering information scattered across a normalized schema into a single result, the join condition, table aliases, the way unmatched rows drop out, and how row count can multiply.
Contents
The previous topic built queries that read from a single table: column selection, conditions, sorting, null-value behavior, functions. One question remained unanswered by those tools. Looking at the loan table alone does not show which member borrowed which book.
The block below builds the schema and the sample data; every query in this lesson runs
against the library.db file it creates.
rm -f library.db sqlite3 library.db <<'SQL' CREATE TABLE branch (branch_id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL); CREATE TABLE book (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL, publication_year INTEGER, branch_id INTEGER REFERENCES branch(branch_id)); CREATE TABLE member (member_id INTEGER PRIMARY KEY, first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT, registered_at TEXT NOT NULL); CREATE TABLE loan (loan_id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL REFERENCES book(book_id), member_id INTEGER NOT NULL REFERENCES member(member_id), pickup_date TEXT NOT NULL, return_date TEXT); INSERT INTO branch VALUES (1,'Central','Ankara'),(2,'Bahcelievler','Ankara'), (3,'Kadikoy','Istanbul'),(4,'Konak','Izmir'); INSERT INTO book VALUES (1,'Blindness','José Saramago',1995,1),(2,'The Disconnected','Oğuz Atay',1972,1), (3,'The Book of Sand','Jorge Luis Borges',1975,2),(4,'Yaban','Yakup Kadri',1932,2), (5,'Silent House','Orhan Pamuk',1983,3),(6,'Motherland Hotel','Yusuf Atılgan',NULL,3), (7,'Tehlikeli Oyunlar','Oğuz Atay',1973,NULL); INSERT INTO member VALUES (1,'Alice','Kane','[email protected]','2023-02-14'), (2,'Ben','Ortiz','[email protected]','2023-05-30'),(3,'Clara','Diaz',NULL,'2024-01-09'), (4,'Derek','Voss','[email protected]','2024-03-22'),(5,'Grace','Kim',NULL,'2024-11-05'), (6,'Owen','Park','[email protected]','2025-01-18'); INSERT INTO loan VALUES (1,1,1,'2025-01-10','2025-01-24'),(2,2,1,'2025-02-02','2025-02-20'), (3,1,2,'2025-02-11',NULL),(4,3,3,'2025-03-01','2025-03-15'),(5,4,3,'2025-03-18','2025-04-02'), (6,1,4,'2025-04-05','2025-04-19'),(7,5,4,'2025-04-21',NULL),(8,2,5,'2025-05-02','2025-05-30'), (9,7,1,'2025-05-14','2025-05-28'),(10,3,5,'2025-06-03',NULL),(11,6,2,'2025-06-11','2025-06-25'), (12,4,4,'2025-06-20','2025-07-04'); SQL
sqlite3 library.db <<'SQL' .headers on .mode column SELECT loan_id, book_id, member_id, pickup_date FROM loan WHERE loan_id <= 4 ORDER BY loan_id; SQL
loan_id book_id member_id pickup_date ------- ------- --------- ----------- 1 1 1 2025-01-10 2 2 1 2025-02-02 3 1 2 2025-02-11 4 3 3 2025-03-01
All that is visible is identifiers. This is not a shortcoming; it is the direct consequence of normalization: a member’s name is written once, in the member table, and the loan record holds only a foreign key pointing to it. The information is deliberately scattered; the query’s job is to gather it back together, temporarily. Gathering it back together is called a join.
Join Condition
A join matches the rows of two tables against a condition. Its syntax is built from the
JOIN and ON clauses.
sqlite3 library.db <<'SQL' .headers on .mode column SELECT l.loan_id, m.first_name, m.last_name, l.pickup_date FROM loan AS l JOIN member AS m ON l.member_id = m.member_id WHERE l.loan_id <= 4 ORDER BY l.loan_id; SQL
loan_id first_name last_name pickup_date ------- ---------- --------- ----------- 1 Alice Kane 2025-01-10 2 Alice Kane 2025-02-02 3 Ben Ortiz 2025-02-11 4 Clara Diaz 2025-03-01
Identifiers turned into names. The condition in the ON clause matches the foreign key on
the loan record against the primary key in the member table. This is the most common form
of join: the one written with an equality condition, carrying the name inner join.
When the word JOIN is written alone, an inner join is understood; the spelling
INNER JOIN means the same thing.
The spellings AS l and AS m give the tables a table alias. When both tables carry
a column with the same name — here, member_id appears in both — the alias determines
which table’s column is meant. Single-letter aliases are common, but readability drops as
the number of tables grows; meaningful abbreviations serve better.
Multiple Tables
A join can be chained. Because the loan record refers to both the member and the book, two joins together answer the whole question.
sqlite3 library.db <<'SQL' .headers on .mode column SELECT l.loan_id, m.first_name || ' ' || m.last_name AS member, b.title, l.pickup_date FROM loan AS l JOIN member AS m ON l.member_id = m.member_id JOIN book AS b ON l.book_id = b.book_id ORDER BY l.loan_id; SQL
loan_id member title pickup_date ------- ---------- ----------------- ----------- 1 Alice Kane Blindness 2025-01-10 2 Alice Kane The Disconnected 2025-02-02 3 Ben Ortiz Blindness 2025-02-11 4 Clara Diaz The Book of Sand 2025-03-01 5 Clara Diaz Yaban 2025-03-18 6 Derek Voss Blindness 2025-04-05 7 Derek Voss Silent House 2025-04-21 8 Grace Kim The Disconnected 2025-05-02 9 Alice Kane Tehlikeli Oyunlar 2025-05-14 10 Grace Kim The Book of Sand 2025-06-03 11 Ben Ortiz Motherland Hotel 2025-06-11 12 Derek Voss Yaban 2025-06-20
All twelve records in the loan table came back. Each join result is a new relation and becomes the input to the next join; the order it is written in is only the order on the page — the order the engine actually applies can differ. The rule of a declarative language holds here as well.
Unmatched Rows Drop Out
The defining property of the inner join is that rows failing the condition never appear in the result at all. The query joining books with branches shows this.
sqlite3 library.db <<'SQL' .headers on .mode column SELECT b.title, br.name AS branch FROM book AS b JOIN branch AS br ON b.branch_id = br.branch_id ORDER BY b.book_id; SQL
title branch ---------------- ------------ Blindness Central The Disconnected Central The Book of Sand Bahcelievler Yaban Bahcelievler Silent House Kadikoy Motherland Hotel Kadikoy
Six rows came back, though the book table holds seven books. The missing one is
Tehlikeli Oyunlar: because its branch id is null, the condition
b.branch_id = br.branch_id is never true for any branch row. The rule from the fifth
lesson applies here as well — equality against a null value produces neither true nor
false, and a join only lets true through.
sqlite3 library.db <<'SQL' .headers on .mode column SELECT COUNT(*) AS book FROM book; SELECT COUNT(*) AS join_result FROM book AS b JOIN branch AS br ON b.branch_id = br.branch_id; SQL
book ---- 7 join_result ----------- 6
The silence of the loss matters: the query raises no error, it returns one row short. When a report’s numbers do not add up, one of the first places to look is the rows an inner join has eliminated. When unmatched rows need to be kept as well, an outer join is written; that is the next lesson’s subject.
Row Count Can Grow, Too
A join does not only drop rows; it can multiply them. When one row in one table matches several rows in the other table, it appears in the result as many times as it has matches.
sqlite3 library.db <<'SQL' .headers on .mode column SELECT b.title, l.loan_id, l.pickup_date FROM book AS b JOIN loan AS l ON b.book_id = l.book_id WHERE b.title = 'Blindness' ORDER BY l.loan_id; SQL
title loan_id pickup_date --------- ------- ----------- Blindness 1 2025-01-10 Blindness 3 2025-02-11 Blindness 6 2025-04-05
The single row for this book in the book table appeared three times once joined with the
loan records. This is correct — there really are three separate loan transactions — but it
turns into a trap during counting: counting books through this result counts Blindness
three times over. This distinction returns and is examined more closely when aggregate
functions are covered.
General rule: in a one-to-many relationship, a join repeats the rows on the “one” side as many times as there are matches on the “many” side.
Shorthand Syntax
Two shorter spellings of a join exist, and both call for caution.
The first is the USING clause, which bases the join condition on a column carrying the
same name in both tables.
sqlite3 library.db <<'SQL' .headers on .mode column SELECT title, name AS branch FROM book JOIN branch USING (branch_id) ORDER BY book_id; SQL
title branch ---------------- ------------ Blindness Central The Disconnected Central The Book of Sand Bahcelievler Yaban Bahcelievler Silent House Kadikoy Motherland Hotel Kadikoy
USING (branch_id) gives the same result as ON b.branch_id = br.branch_id and shows the
shared column once in the result. It is useful when naming is consistent; it cannot be
used when column names differ.
The second is the join that writes no condition at all: the natural join. Every column carrying the same name in both tables is taken as the join condition. Its brevity comes at the cost of silent error.
sqlite3 library.db <<'SQL' .headers on .mode column SELECT 'member NATURAL JOIN branch row count: ' || COUNT(*) AS measurement FROM member NATURAL JOIN branch; SQL
measurement ---------------------------------------- member NATURAL JOIN branch row count: 24
The member table and the branch table carry no real relationship, and in this schema they also share no column name at all. With nothing to match on, the natural join falls back to a plain cross join: every member row is paired with every branch row, six times four, twenty-four in total. No error message appears; only a suspiciously large result.
The real danger runs the other way: a column added to a schema later can happen to share a name with a column in an unrelated table. A natural join that worked correctly one day can silently start computing something else the next, matching rows on a name that carries no real relationship at all. Writing the join condition explicitly removes this entire class of error before it can occur.
One more old spelling exists: separating tables with a comma and placing the condition
inside WHERE.
sqlite3 library.db <<'SQL' .headers on .mode column SELECT b.title, br.name AS branch FROM book AS b, branch AS br WHERE b.branch_id = br.branch_id ORDER BY b.book_id; SQL
title branch ---------------- ------------ Blindness Central The Disconnected Central The Book of Sand Bahcelievler Yaban Bahcelievler Silent House Kadikoy Motherland Hotel Kadikoy
The result is the same, but it carries two flaws. The join condition and the filter
condition mix together in the same clause, and when the condition is forgotten the query
produces every matching pair without ever raising an error. The ON syntax keeps the
condition next to the join and prevents both.
Summary
- In a normalized schema, information is deliberately scattered; a join gathers it back together for the duration of a query.
- An inner join returns only the row pairs for which the condition is true; an unmatched row drops out without error.
- A foreign key carrying a null value matches no row and is lost in an inner join.
- In a one-to-many relationship, a join repeats the row on the “one” side as many times as it has matches.
- Table aliases distinguish identically named columns and keep multi-table queries readable.
USINGshortens a join on an identically named column; a natural join leaves the condition to name similarity and is open to silent error.
Next Step
An inner join dropping unmatched rows is, for some questions, exactly what is not wanted: questions such as “which branch has no books at all” or “which member has never borrowed anything” are asking precisely for the rows that have no match. The next lesson builds the outer joins that keep those rows, shows how the preserved rows fill with null values, and measures how the right and full outer joins depend on engine support.
To keep your progress and take notes, Log in
My notes
Log in to take notes.