SQL & PostgreSQL
Joins: Combining Tables
Primary and foreign keys, inner, left, right, full and cross joins with drawn result tables, self joins and the NULL trap in left joins.
On this page
In one line: a join glues rows from two tables together where a condition matches, usually "this foreign key equals that primary key". INNER JOIN keeps only the matches, LEFT JOIN also keeps every row from the left table (with NULLs where nothing matched), and the classic bug is putting a filter on the right table in WHERE, which silently turns your LEFT JOIN back into an inner join.
In lesson 0 we split the shop into four tables. That is on purpose: a customer's name is stored once, not copied into every order. But now the data you want is in two or three places at once, and you need a way to put it back together. That is what joins do.
This lesson uses the shop from lesson 0. If your data is changed, run setup.sql again.
What I ran / checked. Every query below was run on a real PostgreSQL 16.15 with the exact shop data from lesson 0, and the result tables are pasted from psql. A few small demo tables (a, b, sizes, colors) are temporary tables made only for drawing the join types. I did not run EXPLAIN on joins here; that is lesson 5.
Words used in this lesson
- A primary key (PK) is a column whose value is unique and not NULL in every row. It names one row. Here that is
customers.id. - A foreign key (FK) is a column that must match a primary key in another table.
orders.customer_idis a foreign key tocustomers.id. The database refuses a value that has no match. - A join combines rows of two tables into one result.
- An alias is a short name for a table, such as
cforcustomers, so you can writec.name. - One-to-many: one customer has many orders. Many-to-many: one order has many products, and one product is in many orders. A third table (here
order_items) sits between them. - A self join joins a table to itself.
- An anti-join finds rows that have no match in another table.
Keys: how tables point at each other
customers (id PK) <----- orders (customer_id FK) <----- order_items (order_id FK, product_id FK) -----> products (id PK)The foreign key is a promise that the database keeps for you. Try to make an order for a customer that does not exist, or to delete a customer who still has orders:
INSERT INTO orders (customer_id, ordered_at) VALUES (99, now());DELETE FROM customers WHERE id = 4;ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey"DETAIL: Key (customer_id)=(99) is not present in table "customers".ERROR: update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders"DETAIL: Key (id)=(4) is still referenced from table "orders".So in this schema every order has a real customer. Lesson 4 covers what else a foreign key can do on delete.
INNER JOIN
Question: which customer placed each order? Each order row has a customer_id. Match it to customers.id:
SELECT o.id, c.name, o.statusFROM orders oJOIN customers c ON c.id = o.customer_idORDER BY o.id; id | name | status ----+------+----------- 1 | Asha | paid 2 | Asha | shipped 3 | Ben | paid 4 | Chen | cancelled 5 | Chen | shipped 6 | Asha | paid 7 | Dara | new(7 rows)JOIN alone means INNER JOIN. Think of it as: for every order row, find the customer rows where ON is true, and glue them side by side. Eli and Fay have no orders, so they appear nowhere. Inner join keeps only matches.
The join types, drawn
To see all the types clearly, use two tiny tables. a has x = 1, 2, 3 and b has x = 2, 3, 4. Only 2 and 3 exist in both.
a b x | la x | lb---+---- ---+---- 1 | a1 2 | b2 2 | a2 3 | b3 3 | a3 4 | b4INNER JOIN: only rows that match.
SELECT a.x AS ax, a.la, b.x AS bx, b.lb FROM a JOIN b ON a.x = b.x; ax | la | bx | lb ----+----+----+---- 2 | a2 | 2 | b2 3 | a3 | 3 | b3LEFT JOIN (also LEFT OUTER JOIN): every row of the left table (the one written before JOIN); the right side is filled with NULL when nothing matches.
ax | la | bx | lb ----+----+----+---- 1 | a1 | | 2 | a2 | 2 | b2 3 | a3 | 3 | b3RIGHT JOIN: the mirror image: every row of the right table.
ax | la | bx | lb ----+----+----+---- 2 | a2 | 2 | b2 3 | a3 | 3 | b3 | | 4 | b4FULL JOIN: every row from both sides, matched where possible.
ax | la | bx | lb ----+----+----+---- 1 | a1 | | 2 | a2 | 2 | b2 3 | a3 | 3 | b3 | | 4 | b4(The last three are the same query with LEFT JOIN, RIGHT JOIN and FULL JOIN in place of JOIN.) A simple picture: inner is the overlap; left is the overlap plus the left-only rows; right is the overlap plus the right-only rows; full is everything.
In practice most people write LEFT JOIN and almost never RIGHT JOIN, because you can always swap the table order. FULL JOIN is rare: it is for comparing two lists ("what is in one but not the other").
LEFT JOIN on the shop
Which customers placed orders, and who did not?
SELECT c.name, o.id AS order_id, o.statusFROM customers cLEFT JOIN orders o ON o.customer_id = c.idORDER BY c.id, o.id; name | order_id | status ------+----------+----------- Asha | 1 | paid Asha | 2 | shipped Asha | 6 | paid Ben | 3 | paid Chen | 4 | cancelled Chen | 5 | shipped Dara | 7 | new Eli | | Fay | | (9 rows)Nine rows from six customers: Asha appears three times (one row per order). This is the key fact about joins: the result has one row for every matching pair. A one-to-many join repeats the "one" side. Eli and Fay stay in because of LEFT, with NULL for the order columns.
CROSS JOIN
CROSS JOIN has no condition: it pairs every left row with every right row (a "Cartesian product"). It is useful for making combinations:
SELECT * FROM sizes CROSS JOIN colors; size | color ------+------- S | red S | blue S | green M | red M | blue M | green(6 rows)2 sizes times 3 colours is 6 rows. On real tables this explodes: 10,000 times 10,000 is 100 million rows.
A forgotten join condition is a cross join. If a join query returns far too many rows, check the ON first.
Self joins: a table joined to itself
customers.referred_by holds the id of the customer who brought someone in. To show the name of the referrer, join customers to itself. You must use two different aliases, c for the customer and r for the referrer:
SELECT c.name AS customer, r.name AS referred_byFROM customers cLEFT JOIN customers r ON r.id = c.referred_byORDER BY c.id; customer | referred_by ----------+------------- Asha | Ben | Asha Chen | Asha Dara | Ben Eli | Fay | (6 rows)It is a LEFT JOIN so customers who were not referred (NULL) are kept. Any tree-like data (employee and manager, category and parent category) works this way. Lesson 3 shows how to walk a whole tree with a recursive CTE.
Join condition (ON) versus WHERE
For an inner join, ON and WHERE give the same rows, so people mix them up. For a left join they are very different:
ONdecides which right-side rows match. Left rows with no match are still kept.WHEREruns after the join and removes rows from the result.
Say we want each customer and their paid orders. First, the filter in ON:
SELECT c.name, o.id AS order_id, o.statusFROM customers cLEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'ORDER BY c.id, o.id; name | order_id | status ------+----------+-------- Asha | 1 | paid Asha | 6 | paid Ben | 3 | paid Chen | | Dara | | Eli | | Fay | | (7 rows)All customers stay. Chen and Dara have orders, but none are paid, so their order columns are NULL. Now the same filter moved to WHERE:
SELECT c.name, o.id AS order_id, o.statusFROM customers cLEFT JOIN orders o ON o.customer_id = c.idWHERE o.status = 'paid'ORDER BY c.id, o.id; name | order_id | status ------+----------+-------- Asha | 1 | paid Asha | 6 | paid Ben | 3 | paid(3 rows)Eli, Fay, Chen and Dara are gone. The join did produce rows for them with o.status NULL, but NULL = 'paid' is unknown (lesson 0), and WHERE drops unknown rows. The LEFT JOIN now behaves like an inner join.
The LEFT JOIN + WHERE trap. A WHERE condition on a column of the right table (other than IS NULL) throws away the rows that the LEFT JOIN was supposed to keep. Put right-table filters in ON. Put left-table filters in WHERE.
Anti-join: rows with no match
"Which customers have never ordered?" There are three common ways. The first is NOT EXISTS:
SELECT c.nameFROM customers cWHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id)ORDER BY c.name; name ------ Eli Fay(2 rows)For each customer, the subquery asks "is there at least one order?". NOT EXISTS is true when there is none. (The SELECT 1 is just a placeholder: EXISTS only cares whether any row comes back.) PostgreSQL turns this into an efficient "anti join".
The second is a LEFT JOIN followed by WHERE ... IS NULL on a column that cannot be NULL when a match exists (the primary key):
SELECT c.name FROM customers cLEFT JOIN orders o ON o.customer_id = c.id WHERE o.id IS NULL;It returns the same two rows, Eli and Fay. This is the one time a right-table condition in WHERE is correct: you want the unmatched rows.
The third way, NOT IN, is the trap from lesson 0. Say we want customers who have not referred anyone. A customer who referred nobody is one whose id never appears in referred_by:
SELECT name FROM customers WHERE id NOT IN (SELECT referred_by FROM customers); name ------(0 rows)Nothing, which is wrong. The referred_by column contains NULLs, and id NOT IN (..., NULL) is never true. With NOT EXISTS the answer is right:
SELECT c.name FROM customers cWHERE NOT EXISTS (SELECT 1 FROM customers r WHERE r.referred_by = c.id)ORDER BY c.id; name ------ Chen Dara Eli Fay(4 rows)Many-to-many and joining three or four tables
An order has many products, and a product is in many orders. The table order_items is the junction table (or link table) that holds one row per pair. Its primary key is (order_id, product_id), so the same product cannot appear twice in one order. To see what was in orders 1 and 3 with the customer name, chain the joins:
SELECT o.id AS order_id, c.name, p.name AS product, oi.quantity, oi.unit_priceFROM orders oJOIN customers c ON c.id = o.customer_idJOIN order_items oi ON oi.order_id = o.idJOIN products p ON p.id = oi.product_idWHERE o.id IN (1, 3)ORDER BY o.id, p.id; order_id | name | product | quantity | unit_price ----------+------+----------+----------+------------ 1 | Asha | Notebook | 2 | 1 | Asha | Pen | 5 | 1.20 3 | Ben | Notebook | 1 | 3 | Ben | Backpack | 1 | 45.00 |(4 rows)Read the joins from top to bottom: each JOIN ... ON adds one table to the result built so far. Notice unit_price is stored on the line, not only on the product. This is on purpose: it records the price at the time of sale, even if the product's price changes later.
Join fan-out: the double-counting bug
Each join can multiply rows. We have 7 orders. After joining their items:
SELECT count(*) AS joined_rows FROM orders o JOIN order_items oi ON oi.order_id = o.id; joined_rows ------------- 12Seven orders became 12 rows, because an order with 3 lines appears 3 times. If you then add up something that lives on the order (such as a shipping fee), you count it once per line. Rule: when you join a "one" table to a "many" table, summing a column from the "one" side gives wrong totals. Aggregate first, or sum only columns from the "many" side. Lesson 2 shows both ways.
Useful details
USING (col)is a shortcut when both columns have the same name. Careful withorders.idandcustomers.id: they have the same name but mean different things, soJOIN customers c USING (id)compiles and gives nonsense. I ran it and got six meaningless "matches", such as Asha with order 1 and Fay with order 6.NATURAL JOINjoins on all same-named columns. It breaks the day someone adds a column. Avoid it.- Foreign key columns get no index automatically in PostgreSQL (primary keys do). Lesson 5 shows how to add them.
Try it
- List each order with the customer's city (inner join).
- List every product with how many order lines it appears in, including products never sold. (Hint:
LEFT JOINandcount(oi.order_id).) - Find customers with no paid orders.
- For each customer who was referred, show the referrer's name and city.
Answers
SELECT o.id, c.name, c.city FROM orders o JOIN customers c ON c.id = o.customer_id;SELECT p.name, count(oi.order_id) FROM products p LEFT JOIN order_items oi ON oi.product_id = p.id GROUP BY p.name;gives Gift card 0.count(*)would wrongly give 1 here, because the unmatched row still counts as a row. Lesson 2 explains why.SELECT c.name FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.status = 'paid');gives Chen, Dara, Eli, Fay. The status test belongs inside the subquery.SELECT c.name, r.name AS referrer, r.city FROM customers c JOIN customers r ON r.id = c.referred_by;gives Ben (Asha, Pune), Chen (Asha, Pune), Dara (Ben, London).
Interview questions
Explain INNER, LEFT, RIGHT and FULL joins. (must know)
Inner keeps only rows that match on both sides. Left keeps all rows from the left table and fills the right columns with NULL when there is no match. Right is the mirror. Full keeps unmatched rows from both sides. I mostly write inner and left joins, and swap table order instead of using right.
What is the difference between a filter in ON and a filter in WHERE for a LEFT JOIN? (trap)
A filter in ON decides which right rows match, and left rows with no match are still returned with NULLs. A filter in WHERE runs after the join and removes rows, so a condition on the right table drops the NULL rows and the query acts like an inner join. For right-table filters I use ON.
How do you find rows that have no match in another table? (must know)
I use NOT EXISTS with a correlated subquery, or LEFT JOIN ... WHERE right.pk IS NULL. PostgreSQL plans both as an anti join. I avoid NOT IN because if the subquery returns a NULL, the whole result is empty.
What is a self join and when do you use it? (must know)
It joins a table to itself using two aliases. I use it for hierarchical data stored in one table, such as employee and manager, or the referrer of a customer: customers c LEFT JOIN customers r ON r.id = c.referred_by.
How do you model and query many-to-many data? (must know)
With a junction table that has a foreign key to each side and usually a composite primary key, like order_items(order_id, product_id). To query, I join through it: orders to order_items to products. Extra facts about the pair, like quantity or price at sale time, live in the junction table.
A report shows revenue twice as high as it should be after adding a join. Why? (how would you debug)
A join to a "many" table multiplied the rows, so a value from the "one" side was summed once per matching row. I check the row count before and after the join. Then I either aggregate the many side first in a subquery or CTE and join the result, or sum only columns that live on the many side.
Do foreign keys create indexes in PostgreSQL? (senior signal)
No. The primary key side has an index, but the referencing column does not get one automatically. Without it, joins and cascading deletes on that column can scan the whole table. I add an index on foreign key columns I join or delete by. Lesson 5 covers this.
What happens to child rows when you delete a parent row? (senior signal)
By default the foreign key refuses the delete, as we saw with customer 4 (the error says the key is still referenced). I can choose ON DELETE CASCADE (delete children too), SET NULL or RESTRICT per foreign key. For orders I usually keep the default, because deleting history is rarely what the business wants.
Key takeaways
- A foreign key points to a primary key. A join puts the pieces back together with
ON a.fk = b.pk. - Inner keeps matches only. Left keeps every left row. Full keeps both sides. Cross has no condition and multiplies.
- A join returns one row per matching pair, so a one-to-many join repeats the "one" side. Watch for double counting.
- With
LEFT JOIN, right-table filters go inON, notWHERE, unless you are looking forIS NULL. - For "has no match", use
NOT EXISTS, notNOT IN. - Many-to-many needs a junction table. Self joins use two aliases.
What's next
Lesson 2: Aggregates, GROUP BY and HAVING. Now that you can combine tables, you will count, sum and average them: orders per customer, revenue per month, and the difference between WHERE and HAVING. For more on Prisma and SQL questions, see Full-Stack Q&A, Lesson 08.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Database Fundamentals for API Engineers: Indexes, Transactions, Isolation · Backend in Depth
- Design Pagination: Offset vs Cursor (Keyset) · Practical Design Problems
- Prisma & SQL Interview Questions · Full-Stack Questions
Part of the study guide
Related series