Skip to content
Ameen Abozaid
PublishedBEG-050-L06

Joins

  • Lesson 6 of 9
  • 12 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

Your library data lives in four tables on purpose: BEG-049 taught you to store each fact once and connect tables with foreign keys. The price is that a question such as "who has The Dispossessed out right now?" needs data from three tables. A join puts related rows side by side, matching them through the keys, so one query can answer it.

In the library picture, the loan slip says only "member 3, book 1". To read it as "Carla, The Dispossessed", the librarian looks up member 3 in the member file and book 1 in the catalog. A join does that lookup for every row at once.

There are two joins to learn well: the inner join and the left join. The difference between them is the second most common source of wrong answers in SQL, after NULL. A wrong choice does not give an error; it silently loses rows.

This lesson builds on Lessons 4 and 5, and on BEG-049 (primary keys and foreign keys).

Learning Objectives

By the end of this lesson you will be able to:

  • Write an inner join with an ON condition that matches a foreign key to a primary key.
  • Use short table aliases and qualify column names to avoid ambiguity.
  • Write a left join and explain what the NULLs in the result mean.
  • Explain the difference between inner and left joins, and say which one answers a given question.
  • Find rows that have no match using a left join and IS NULL.
  • Join three tables, and count correctly across a left join.

Written Explanation

The inner join

sql
SELECT b.title, a.name
FROM books b
INNER JOIN authors a ON b.author_id = a.id;
  • FROM books b names the first table and gives it the alias b; INNER JOIN authors a adds the second with the alias a.
  • ON b.author_id = a.id is the join condition: a book and an author are paired when the book's foreign key equals the author's primary key. The pattern is almost always foreign key = primary key.
  • Columns are written with the alias, b.title, a.name. This is required whenever a name exists in both tables (both tables have id and name), otherwise PostgreSQL stops with column reference "id" is ambiguous. It is good practice anyway.

An inner join returns a row for each pair of rows that satisfies the condition, and drops rows that have no partner on the other side. The PostgreSQL manual says it the same way: for each row in the first table, one joined row per matching row of the second; the rows without a match do not appear. INNER is optional: plain JOIN means inner join.

The left join

sql
SELECT m.name, l.id AS loan_id
FROM members m
LEFT JOIN loans l ON l.member_id = m.id;

A left join keeps every row of the left table (the one written before LEFT JOIN). A left row with a match appears once per match; a left row with no match appears once, with NULL in all the right-hand columns. The manual describes it as an inner join followed by adding the unmatched left rows, so the result has at least one row for every left row. LEFT JOIN is short for LEFT OUTER JOIN.

Which one do I need?

Ask: "Can a row on the left have no partner on the right, and do I still want to see it?"

QuestionJoin
Each book with its author (every book has an author, enforced by NOT NULL and the foreign key)Inner join is safe
Each loan with its member and bookInner join is safe: a loan always has both
Every member and their loans, including members with noneLEFT JOIN from members
Members who have never borrowedLEFT JOIN + WHERE l.id IS NULL

"Left" and "right" refer only to the position in the text. Swapping the table order and writing RIGHT JOIN gives the same result; most people simply always write left joins.

Finding rows with no match

The left join leaves NULL in the right columns exactly when no match exists. So filtering on a right-hand column that can never be NULL in a real row (the primary key) finds the unmatched rows:

sql
SELECT m.name
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
WHERE l.id IS NULL;

This is the standard answer to "members with no loans".

Joining three tables

Chain more joins, each with its own ON:

sql
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books   b ON b.id = l.book_id

loans is the junction between members and books, so it is the natural starting point.

Counting across a left join

After a left join, COUNT(*) counts rows, and a member with no loans still produces one row (with NULLs). Use COUNT(l.id), which skips NULL, to count real loans. This is Lesson 5's COUNT(*) versus COUNT(column) again.

Conditions in WHERE versus ON

A WHERE condition on a right-hand column, such as WHERE l.loaned_on >= '2025-10-01', is tested after the join. For the NULL-extended row of a member without loans the condition is not true, so that member disappears again, and your left join has quietly become an inner join. When you want to restrict which right-hand rows are matched while keeping every left row, put the condition in the ON: LEFT JOIN loans l ON l.member_id = m.id AND l.loaned_on >= '2025-10-01'.

Examples

Real output, PostgreSQL 18.3, seeded library: 5 members, 8 loans, 11 books, 5 authors.

Example 1: an inner join with aliases (simple)

Books published after 1990, with the author's name:

sql
SELECT b.title, a.name
FROM books b
INNER JOIN authors a ON b.author_id = a.id
WHERE b.published_year > 1990
ORDER BY b.title;
text
        title         |      name      
----------------------+----------------
 Fledgling            | Octavia Butler
 Lavinia              | Ursula Le Guin
 Parable of the Sower | Octavia Butler
(3 rows)

Joining all books to authors gives 11 rows, the same as the number of books, because every book has exactly one author. The WHERE and ORDER BY work as before, now over columns from both tables. Leaving out the aliases gives ERROR: column reference "id" is ambiguous, as SELECT id, name FROM members m JOIN loans l ON ... showed when we tried it.

Example 2: inner versus left on members and loans (practical)

The same question, "members and their loans", with the two joins. First the inner join:

sql
SELECT m.name, l.id AS loan_id
FROM members m
JOIN loans l ON l.member_id = m.id
ORDER BY m.id, l.id;
text
 name  | loan_id 
-------+---------
 Amal  |       1
 Amal  |       2
 Amal  |       8
 Bilal |       3
 Bilal |       4
 Carla |       5
 Carla |       6
 Dina  |       7
(8 rows)

Eight rows; Emil, the fifth member, is not there. Now the left join:

sql
SELECT m.name, l.id AS loan_id
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
ORDER BY m.id, l.id;
text
 name  | loan_id 
-------+---------
 Amal  |       1
 Amal  |       2
 Amal  |       8
 Bilal |       3
 Bilal |       4
 Carla |       5
 Carla |       6
 Dina  |       7
 Emil  |        
(9 rows)

Nine rows: Emil appears with an empty (NULL) loan id. That one row is the difference between the joins. Counting the members first would have revealed it: the members table has five rows, and the inner join output covers four. A member with several loans appears several times, once per loan, in both versions.

Example 3: no match, counting, and three tables (realistic)

Members who have never borrowed:

sql
SELECT m.name
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
WHERE l.id IS NULL;
text
 name 
------
 Emil
(1 row)

Loans per member, including members with none. The wrong count and the right count side by side:

sql
SELECT m.name, COUNT(*) AS n_star, 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 m.id;
text
 name  | n_star | loans 
-------+--------+-------
 Amal  |      3 |     3
 Bilal |      2 |     2
 Carla |      2 |     2
 Dina  |      1 |     1
 Emil  |      1 |     0

COUNT(*) says Emil has one loan; COUNT(l.id) correctly says zero. (We group by m.id and m.name: the select list may only contain grouped columns and aggregates, from Lesson 5.)

Finally, three tables: who has which book out, and when is it due?

sql
SELECT m.name, b.title, l.due_on
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books   b ON b.id = l.book_id
WHERE l.returned_on IS NULL
ORDER BY l.due_on;
text
 name  |        title         |   due_on   
-------+----------------------+------------
 Amal  | A Wizard of Earthsea | 2025-09-15
 Bilal | Kindred              | 2025-10-04
 Carla | The Dispossessed     | 2025-10-24
 Dina  | Palace Walk          | 2025-11-03
(4 rows)

The loan slips now read as human sentences. (Whether any of these is overdue depends on today's date, which Lesson 8 will compare against.)

Backend Insight

Almost every endpoint of a real backend joins tables: an order page joins orders, customers, and order_items; a profile joins users and addresses. Because data is normalized (BEG-049), joins are the normal way to read it back together.

Choosing the wrong join is a quiet bug: a report built on an inner join looks right, runs fast, and gives a smaller number than the truth. Always ask "can there be no match?" before choosing, and check the totals against a simple COUNT(*) of the left table. In frameworks that generate SQL for you (which you will meet later), the same question applies to how relationships are fetched.

Joins are efficient when the columns in the ON condition are indexed. Primary keys always are, in PostgreSQL; indexes on foreign keys are something you add deliberately (a later topic).

Common Mistakes

Mistake 1: Using an inner join when rows may have no match

  • Incorrect: FROM members m JOIN loans l ... for "all members and their loans"
  • Correct: FROM members m LEFT JOIN loans l ...

Mistake 2: Forgetting the ON condition's meaning

Joining ON m.id = l.id (primary key to primary key) compiles and returns nonsense: members paired with the wrong loans. Match foreign key to primary key: l.member_id = m.id.

Mistake 3: Filtering the right table in WHERE after a left join

It turns the left join into an inner join, because the NULL rows fail the test. Use ON for such conditions, or test IS NULL deliberately.

Mistake 4: COUNT(*) after a left join

It counts the NULL-extended row as a loan. Count a right-hand column, COUNT(l.id).

Mistake 5: Joining without any condition

FROM members m, loans l has no condition, so every member is paired with every loan: with 5 members and 8 loans we got 40 rows. This is called a Cartesian product, and it is almost never what you want.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. Write a query listing each book's title with its author's name.

Show solution A1

A1. SELECT b.title, a.name FROM books b JOIN authors a ON a.id = b.author_id;

A2. Which condition joins loans to books?

Show solution A2

A2. l.book_id = b.id: the foreign key in loans equals the primary key of books.

A3. What does NULL in the right-hand columns of a left join result mean?

Show solution A3

A3. The left row has no matching row on the right: nothing was found, so those columns are filled with NULL.

Level B: Application

B1. List all authors with the number of books each has, including authors with none, highest first. Which count expression do you use?

Show solution B1

B1.

sql
SELECT a.name, COUNT(b.id) AS books
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.id, a.name
ORDER BY books DESC;

COUNT(b.id) ignores the NULL of an author without books, giving 0 for such an author.

B2. List books that have never been loaned. Which join, and which WHERE? (On the seeded data you should see four titles.)

Show solution B2

B2.

sql
SELECT b.title
FROM books b
LEFT JOIN loans l ON l.book_id = b.id
WHERE l.id IS NULL
ORDER BY b.title;

A left join from books, keeping the rows where no loan matched. The result was Arrow of God, Collected Verses, Fledgling, and Lavinia.

B3. Show each member's name with the titles of the books they have currently out (not returned).

Show solution B3

B3.

sql
SELECT m.name, b.title
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books b ON b.id = l.book_id
WHERE l.returned_on IS NULL;

Level C: Challenge

C1. Your boss asks for "all members, with their open loans if any". A colleague wrote FROM members m LEFT JOIN loans l ON l.member_id = m.id WHERE l.returned_on IS NULL and says it works. Explain why it appears to work on the seeded data, why it is still risky, and write a version that is correct for the question.

Show solution C1

C1. It appears to work because Emil, who has no loans, gets NULL in returned_on from the missing row and so passes IS NULL. But a member whose loans are all returned has real, non-NULL return dates, so every one of their rows fails the test and that member vanishes. The condition cannot tell "no loan at all" from "loan not returned". The correct version moves the condition into ON: LEFT JOIN loans l ON l.member_id = m.id AND l.returned_on IS NULL, which keeps every member and attaches only open loans. On the seeded data both versions return the same five rows, because every member with loans happens to have one open, which is why the error is easy to miss.

C2. A report counts "members who borrowed" as SELECT COUNT(*) FROM members m JOIN loans l ON l.member_id = m.id; and returns 8. Explain what is wrong with the number, and write a query that counts members with at least one loan. (A hint: COUNT(DISTINCT m.id) counts unique values.)

Show solution C2

C2. The inner join returns one row per loan, so the number is 8 loans, not members, and members with several loans are counted several times. The right query is SELECT COUNT(DISTINCT m.id) FROM members m JOIN loans l ON l.member_id = m.id;, which gives 4 on the seeded data. (Using DISTINCT inside COUNT was not covered earlier; it is a preview.)

Mini Challenge

Write three queries, then check each result's row count against a plain count of the table that should be fully shown.

  1. Every book with its author name and, if it has ever been loaned, the id of each loan (books never loaned must still appear).
  2. Every author with the number of loans of their books. (Think about which tables you need, and which joins must be left joins.)
  3. The members who joined in 2024 or later and have no loans.

Check your work: query 1 should have at least 11 rows (the number of books); query 2 should have 5 rows (one per author) and use COUNT(l.id); query 3 uses LEFT JOIN with IS NULL.

Summary & Next Lesson

Key takeaways

  • A join pairs rows from two tables using ON, normally foreign key = primary key.
  • Use aliases and qualify columns (m.name); an unqualified shared name is ambiguous.
  • Inner join: only rows with a match on both sides; unmatched rows silently disappear.
  • Left join: every left row stays; a missing right side becomes NULL.
  • LEFT JOIN ... WHERE right.id IS NULL finds rows with no match.
  • After a left join, count with COUNT(right.id), not COUNT(*).
  • Always ask: "may a row have no match?"

Next lesson

Joins combine tables side by side. The next lesson, "Subqueries", shows another way to use one table to filter another: a query inside a query, which is often clearer when you only need to filter.

Self-check

  1. What does an inner join do with a left row that has no match?
  2. Which join keeps all members, even those without loans?
  3. What does WHERE l.id IS NULL find after a left join from members to loans?
  4. Why is m.id = l.id almost always the wrong join condition?
  5. Why does COUNT(*) after a left join overcount?
Show answers
  1. It drops the row: it does not appear in the result.
  2. LEFT JOIN from members.
  3. Members with no matching loan.
  4. It compares two primary keys, so it pairs unrelated rows; it should compare the foreign key to the primary key.
  5. A left row with no match still yields one row (with NULLs), and COUNT(*) counts it as if it were a real match.

References

  • PostgreSQL documentation, Table Expressions: Joined Tables. Supports the inner join dropping unmatched rows, the left join definition (an inner join, plus unmatched left rows padded with nulls), optional INNER/OUTER keywords, and table aliases being optional with AS.
  • All results, row counts, and error messages (the ambiguous id, the 40-row Cartesian product) were produced on PostgreSQL 18.3. The WHERE-versus-ON behaviour of left joins follows from the manual's definition; I did not copy it from a specific page. The remark about indexing foreign keys is general knowledge and unverified here.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.