Combining tables: JOIN

Customers, orders, and products are stored in separate tables. JOIN reads related rows together by following their keys.

The basic join

Orders store customer_id, while customer names are stored in customers. To see them together, specify how the two tables relate.

1

SELECT o.order_number,
       o.ordered_at,
       o.total,
       c.first_name || ' ' || c.last_name AS customer,
       c.country
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.ordered_at DESC
LIMIT 20;
Run in Planche

Result — first 5 of 20 rows

order_numberordered_attotalcustomercountry
ORD-2025-0024802025-12-28 23:52:146333.77Elena MeyerCA
ORD-2025-0005052025-12-28 11:16:392276.51Diego RossiIT
ORD-2025-0009402025-12-28 07:28:4611000.68Freya ChoiBR
ORD-2025-0015812025-12-28 06:12:382645.06Jonas LeeJP
ORD-2025-0009952025-12-28 05:47:532584.02Leo HaddadUS

Opens Planche in a separate browser tab, with the sample database loaded and this query ready. Every example on the page reuses that tab, and nothing runs until you press F9.

JOIN on its own means INNER JOIN. ON c.id = o.customer_id is the join condition: it specifies how rows from the two tables match. orders o gives the table a short alias, allowing references such as o.total and c.country.

Aliases stop being optional the moment two tables have a column of the same name. Both of these tables have an id; unqualified, id would be ambiguous and the database would refuse the query.

More than two tables

Joins can be chained. An order reaches its products through two steps: orders → order_items → products.

2

SELECT o.order_number,
       p.name AS product,
       oi.quantity,
       oi.unit_price
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status = 'delivered'
ORDER BY o.ordered_at DESC
LIMIT 25;
Run in Planche

Result — first 5 of 25 rows

order_numberproductquantityunit_price
ORD-2025-000505Zen Boots1700.65
ORD-2025-000505Echo Backpack 40L Plus11570.96
ORD-2025-001581Atlas Phone Pro Max11546.38
ORD-2025-001581Pulse Headphones2548.09
ORD-2025-000995Aria Chrono21289.56

Each JOIN adds one more table to the result, so a four-table join follows the same pattern with another line. This query reads as orders, their items, and each item's product.

Keeping rows that have no match

An inner join drops anything without a partner. A customer who has never ordered simply disappears from the result. LEFT JOIN keeps every row of the left table and fills the right side with NULL where there was nothing to match.

3

SELECT c.id,
       c.first_name,
       c.last_name,
       o.order_number,
       o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
ORDER BY c.id
LIMIT 25;
Run in Planche

Result — first 5 of 25 rows

idfirst_namelast_nameorder_numbertotal
1LiamFischerORD-2022-0015961317.56
1LiamFischerORD-2025-0017232664.56
1LiamFischerORD-2025-0031424609.77
2MateoCostaORD-2025-0024733760.6
3YukiJangORD-2025-0035277569.3

This makes “find what is missing” queries possible: keep the left-side rows, then select only those where the right side is NULL.

4

-- The 95 customers who have never ordered anything.
SELECT c.id, c.first_name, c.last_name, c.country
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL
ORDER BY c.id
LIMIT 25;
Run in Planche

Result — first 5 of 25 rows

idfirst_namelast_namecountry
14MayaMoreauUS
17NoraMüllerIT
20SanaParkKR
30RaviParkKR
33OliverParkIN

WHERE o.id IS NULL may look odd because orders.id is a primary key and cannot be NULL in orders. In this result, however, LEFT JOIN supplies NULL for the unmatched right side. Those rows identify the 95 customers who have never ordered.

ON or WHERE: not the same question

Both queries use status = 'cancelled', but they produce different LEFT JOIN results.

5

-- The condition is part of the join: every customer is kept,
-- and only cancelled orders are attached to them.
SELECT c.id, c.first_name, o.order_number, o.status
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'cancelled'
ORDER BY c.id
LIMIT 25;
Run in Planche

Result — first 5 of 25 rows

idfirst_nameorder_numberstatus
1LiamNULLNULL
2MateoNULLNULL
3YukiORD-2025-003530cancelled
4InesNULLNULL
5LeoNULLNULL

The condition sits in ON, so it is part of the matching: every customer is kept, and a cancelled order is attached where one exists. Customers with no cancelled order still appear, with NULLs.

6

-- The same condition in WHERE runs after the join and throws away
-- every row where it is not true — including the NULLs the LEFT JOIN made.
SELECT c.id, c.first_name, o.order_number, o.status
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'cancelled'
ORDER BY c.id
LIMIT 25;
Run in Planche

Result — first 5 of 25 rows

idfirst_nameorder_numberstatus
3YukiORD-2025-003530cancelled
6NoraORD-2023-001551cancelled
6NoraORD-2022-002224cancelled
7PriyaORD-2022-003262cancelled
10ElenaORD-2025-000182cancelled

Here the condition is in WHERE, which runs after the join and removes rows for which it is not true. The NULL rows created by LEFT JOIN fail NULL = 'cancelled', so they are discarded. The result is effectively an INNER JOIN.

As a rule of thumb, put a condition on the right-hand table of a LEFT JOIN in ON, and a condition on the left-hand table in WHERE. With an INNER JOIN the two placements give the same result, which can make this distinction easy to miss.

Joining a table to itself

Nothing says the two sides have to be different tables. Employees have a manager_id pointing at another employee, so the table joins to itself under two different aliases.

7

SELECT e.first_name || ' ' || e.last_name AS employee,
       e.position,
       m.first_name || ' ' || m.last_name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id
ORDER BY manager, employee
LIMIT 25;
Run in Planche

Result — first 5 of 25 rows

employeepositionmanager
Ava MüllerDirectorNULL
Hana TanakaDirectorNULL
Hugo AhmedDirectorNULL
Lucas RomeroDirectorNULL
Maya MüllerDirectorNULL

e and m are the same table seen twice, and the aliases are what make it work at all. It is a LEFT JOIN because the ten people at the top have no manager, and an inner join would quietly leave them out.

What you can do now

You can ask questions across the whole schema. What you cannot do yet is summarize the answer: counting the orders, totalling the revenue and averaging are covered in the next lesson.

Try it yourself

  1. The 15 most recent reviews, showing when it was written, which product it is about, who wrote it, and the rating. That is three tables.

    Show the answer

    Show the answer

    SELECT r.created_at,
           p.name AS product,
           c.first_name || ' ' || c.last_name AS reviewer,
           r.rating
    FROM reviews r
    JOIN products p ON p.id = r.product_id
    JOIN customers c ON c.id = r.customer_id
    ORDER BY r.created_at DESC
    LIMIT 15;
    Run in Planche

    Result — first 5 of 15 rows

    created_atproductreviewerrating
    2025-12-28 21:12:12Sol ChronoHugo Park2
    2025-12-28 16:46:22Ember Catan 2Minjun Novak4
    2025-12-28 14:00:00Quill SpeakerOmar Smith3
    2025-12-28 03:41:42Learning React (Deluxe ed.)Ines Romero4
    2025-12-28 03:40:06Zen Sheet Set 2Kenji Ahmed4

    Two joins, chained: reviews to products for the name, then reviews to customers for the reviewer. Both are inner joins because a review always has both.

  2. Which orders have no shipment recorded against them at all?

    Show the answer

    Show the answer

    SELECT o.order_number, o.status, o.ordered_at
    FROM orders o
    LEFT JOIN shipments s ON s.order_id = o.id
    WHERE s.id IS NULL
    ORDER BY o.ordered_at DESC
    LIMIT 20;
    Run in Planche

    Result — first 5 of 20 rows

    order_numberstatusordered_at
    ORD-2025-000940cancelled2025-12-28 07:28:46
    ORD-2025-003297processing2025-12-28 01:40:38
    ORD-2025-002729cancelled2025-12-27 23:10:11
    ORD-2025-002019paid2025-12-27 09:26:48
    ORD-2025-000337pending2025-12-27 05:29:40

    A LEFT JOIN keeps every order, and WHERE s.id IS NULL keeps only the ones where nothing matched. 1,681 of the 4,000 orders qualify — which makes sense, since pending and cancelled orders are never shipped.