الربط (Joins)
في هذا الدرس 11 موضوعات
هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.
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
ONcondition 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
SELECT b.title, a.name
FROM books b
INNER JOIN authors a ON b.author_id = a.id;FROM books bnames the first table and gives it the aliasb;INNER JOIN authors aadds the second with the aliasa.ON b.author_id = a.idis 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 haveidandname), otherwise PostgreSQL stops withcolumn 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
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?"
| Question | Join |
|---|---|
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 book | Inner join is safe: a loan always has both |
| Every member and their loans, including members with none | LEFT JOIN from members |
| Members who have never borrowed | LEFT 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:
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:
FROM loans l
JOIN members m ON m.id = l.member_id
JOIN books b ON b.id = l.book_idloans 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:
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; 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:
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; 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:
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; 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:
SELECT m.name
FROM members m
LEFT JOIN loans l ON l.member_id = m.id
WHERE l.id IS NULL; name
------
Emil
(1 row)Loans per member, including members with none. The wrong count and the right count side by side:
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; name | n_star | loans
-------+--------+-------
Amal | 3 | 3
Bilal | 2 | 2
Carla | 2 | 2
Dina | 1 | 1
Emil | 1 | 0COUNT(*) 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?
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; 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.
أظهر الحل 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?
أظهر الحل 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?
أظهر الحل 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?
أظهر الحل B1
B1.
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.)
أظهر الحل B2
B2.
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).
أظهر الحل B3
B3.
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.
أظهر الحل 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.)
أظهر الحل 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.
- Every book with its author name and, if it has ever been loaned, the id of each loan (books never loaned must still appear).
- Every author with the number of loans of their books. (Think about which tables you need, and which joins must be left joins.)
- 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 NULLfinds rows with no match.- After a left join, count with
COUNT(right.id), notCOUNT(*). - 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
- What does an inner join do with a left row that has no match?
- Which join keeps all members, even those without loans?
- What does
WHERE l.id IS NULLfind after a left join from members to loans? - Why is
m.id = l.idalmost always the wrong join condition? - Why does
COUNT(*)after a left join overcount?
أظهر الإجابات
- It drops the row: it does not appear in the result.
LEFT JOINfrommembers.- Members with no matching loan.
- It compares two primary keys, so it pairs unrelated rows; it should compare the foreign key to the primary key.
- A left row with no match still yields one row (with
NULLs), andCOUNT(*)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/OUTERkeywords, and table aliases being optional withAS. - All results, row counts, and error messages (the ambiguous
id, the 40-row Cartesian product) were produced on PostgreSQL 18.3. TheWHERE-versus-ONbehaviour 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.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.