Practice17problems on this pattern, easy to hardStart practising →
Pattern 7SQL course / Combining Data

SQL JOINs: Combining Tables Correctly

Learn how INNER, LEFT, RIGHT, FULL, CROSS and self joins combine tables, and how a join can quietly drop rows, multiply them, or match the wrong ones.

21 min read15 sections10 predictions17 practice problems
01

Why do we need JOINs?

A relational database stores similar kinds of information in one table. Multiple tables are used for storing different kinds of information. In our case here, customer details are stored in the customers table, while order details are stored in the orders table. If you look carefully, you will find the customer_id column, which is common in both tables. We say that the customer_id column connects the two tables. For example, order 1001 has customer_id = 1, which refers to Asha.

customers5 rows
customer_idcustomer_namecitycountryreferred_by_customer_id
1AshaBengaluruIndiaNULL
2BenLondonUK1
3CarlaDubaiUAE1
4DevBengaluruIndia2
5EshaSingaporeSingaporeNULL
orders7 rows
order_idcustomer_idproductamountstatusorder_date
10011Wireless Mouse1500paid2026-05-02
10022Mechanical Keyboard3200pending2026-05-03
10033Laptop Sleeve3600paid2026-05-03
10044Office Chair8500cancelled2026-05-04
10051Wireless Mouse750paid2026-05-06
10062Monitor Stand3600paid2026-05-07
1007NULLMechanical Keyboard3200paid2026-05-08

Asha has placed two orders. This is called a one-to-many relationship. It means one customer can have multiple orders. Here, customers is the one side, and orders is the many side.

Before we start, notice two special cases:

  • Order 1007 has no customer_id because it was placed by a guest.
  • Esha (customer 5) has not placed any orders yet.

These examples will help us understand what happens when a row has no match or when one row matches several rows in another table.

Tip
A JOIN connects related rows from two tables using a condition, such as orders.customer_id = customers.customer_id. It lets us return information from both tables in a single result.
02

INNER JOIN: keep rows that match

Suppose we want to show each order along with the customer’s name and country. Order details are stored in orders, while customer details are stored in customers. We can combine them using INNER JOIN:

Query
SELECT
o.order_id,
c.customer_name,
c.country,
o.product,
o.status
FROM orders AS o
INNER JOIN customers AS c
ON c.customer_id = o.customer_id
ORDER BY o.order_id;

The ON condition tells SQL how to match rows from the two tables. For each order, SQL looks for a customer with the same customer_id.

Because we use INNER JOIN, only orders with a matching customer are included.

The aliases o and c are shorter names for orders and customers. They make the query easier to read and show which table each column belongs to. This is especially useful when both tables contain a column named customer_id.

Pause and predictPredict before you continue

The orders table contains 7 rows. How many rows will the INNER JOIN query return?

03

LEFT JOIN: keep every row from left side

Now, let’s say the requirement has changed: show every customer along with their orders, including customers who have never placed an order. Here, using an INNER JOIN would exclude Esha because she has no orders. A LEFT JOIN ensures she is included.

Query
SELECT
c.customer_id,
c.customer_name,
o.order_id,
o.product,
o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
ORDER BY c.customer_id, o.order_id;
Result7 rows
customer_idcustomer_nameorder_idproductstatus
1Asha1001Wireless Mousepaid
1Asha1005Wireless Mousepaid
2Ben1002Mechanical Keyboardpending
2Ben1006Monitor Standpaid
3Carla1003Laptop Sleevepaid
4Dev1004Office Chaircancelled
5EshaNULLNULLNULL

A LEFT JOIN keeps every row from the left table and adds matching rows from the right table. When there is no match, SQL still includes the row from the left table but fills the columns from the right table with NULL. This is why Esha appears with NULL values for her order details. Such a row is called a NULL-extended row.

The result contains 7 rows. Customers with multiple orders appear once for each matching order, just as they would with an INNER JOIN. The key difference is that a LEFT JOIN also includes customers who have no matching orders.

Pause and predictPredict before you continue

Now switch the tables so that orders is on the left side of the LEFT JOIN. What happens to the result?

The query
SELECT
o.order_id,
o.customer_id,
c.customer_name
FROM orders AS o
LEFT JOIN customers AS c
ON c.customer_id = o.customer_id
ORDER BY o.order_id;
Tip
In a LEFT JOIN, always place the table whose rows you want to preserve on the left. Every row from that table stays in the result, whether it finds a match or not.
04

Filtering the right table of a LEFT JOIN

The requirement is to show every customer along with their paid orders. Customers who have never placed a paid order must still appear.

A first attempt might be to use a LEFT JOIN to include all customers, followed by a WHERE clause to filter for paid orders. The query runs without errors, but does it produce the expected result?

Pause and predictPredict before you continue

Which customers will appear in the result?

The query
SELECT
c.customer_name,
o.order_id,
o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status = 'paid'
ORDER BY c.customer_name, o.order_id;
Tip
A condition such as o.status = 'paid' in the WHERE clause removes the unmatched rows preserved by a LEFT JOIN, effectively turning it into an INNER JOIN. This happens because comparisons with NULL evaluate to UNKNOWN, and WHERE keeps only rows where the condition is TRUE.
05

Put right-table conditions in ON

The requirement has two parts: include every customer and show only their paid orders.

The paid-order condition determines which orders can match a customer, so it belongs in the ON clause rather than WHERE.

Query
SELECT
c.customer_name,
o.order_id,
o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status = 'paid'
ORDER BY c.customer_name, o.order_id;
Result6 rows
customer_nameorder_idstatus
Asha1001paid
Asha1005paid
Ben1006paid
Carla1003paid
DevNULLNULL
EshaNULLNULL

Now, only paid orders can match. Dev’s cancelled order does not qualify, but Dev still appears with NULL values for his order details. Esha also remains, even though she has never placed an order.

The important difference is that the condition filters matching orders without removing customers.

If the requirement were instead to “show each paid order with its customer’s name,” the 4-row result from section 04 would be correct. In that case, an INNER JOIN restricted to paid orders would express the requirement more clearly.

What happens when the additional condition in ON filters the left table instead?

Pause and predictPredict before you continue

Which customers appear in the result?

The query
SELECT
c.customer_name,
c.country,
o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND c.country = 'India'
ORDER BY c.customer_id, o.order_id;

Where should we put a condition?

For a LEFT JOIN, the correct placement depends on whether the condition should control which rows match or which rows remain in the result.

ConditionIn ONIn WHERE
Right table (o.status)Restricts which orders match. All customers remain.Filters the result by order status. A condition such as o.status = 'paid' removes customers without paid orders.
Left table (c.country)Restricts which customers can have matching orders. All customers remain.Filters customers from the result.
Tip
In a LEFT JOIN, ON determines which rows match, while WHERE filters the resulting rows. Place a condition in ON when it should restrict matches without removing left-table rows. Use WHERE when you want to exclude rows from the final result.
06

Counting after a LEFT JOIN

LEFT JOIN and GROUP BY are often used together to summarise data.

The requirement is to count the orders placed by each customer, including customers who have never ordered. Those customers should have an order count of 0.

A LEFT JOIN ensures every customer appears, while GROUP BY produces one summary row per customer. However, the choice of COUNT expression matters.

Pause and predictPredict before you continue

If we use COUNT(*) to count orders, what count will Esha receive?

The query
SELECT
c.customer_name,
COUNT(*) AS orders
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY c.customer_id;

COUNT(*) vs COUNT(column)

ExpressionWhat it countsResult for Esha
COUNT(*)All rows, including NULL-extended rows1
COUNT(o.order_id)Rows with a non-NULL order ID0
SUM(o.amount)Sum of non-NULL amountsNULL
COALESCE(SUM(o.amount), 0)Sum of amounts, replacing a NULL result with 00
Tip
After a LEFT JOIN, count the right table’s key, not *. COUNT(*) counts the NULL-extended row as a match, and SUM over it returns NULL.
07

One-to-many joins and totals

Orders are stored in orders table, while their payments are in payments table. Most orders have a single payment, but some are paid using multiple methods. For example, Carla split the payment for order 1003 into two transactions.

payments6 rows
payment_idorder_idamountmethod
PM110011500card
PM210031800card
PM310031800upi
PM41005750upi
PM510063600card
PM610073200card

Finance needs two metrics: the total value of paid orders and the number of payments received.

A natural approach is to join paid orders with their payments and calculate both metrics using aggregate functions. But joining these tables introduces a problem.

Pause and predictPredict before you continue

What value will the query return for order_value?

The query
SELECT
SUM(o.amount) AS order_value,
COUNT(p.payment_id) AS payments
FROM orders AS o
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE o.status = 'paid';

Detecting join fan-out

Before using aggregates on joined tables, check whether the join can produce multiple matches. Here, orders has one row per order, while payments can have multiple rows for the same order_id.

To identify potential fan-out, check whether any order_id appears more than once in payments.

If it does, joining these tables will repeat the corresponding order details.

Why SUM(DISTINCT) is not the solution

You might try SUM(DISTINCT o.amount) to avoid counting repeated amounts. However, DISTINCT removes duplicate values, not duplicate orders.

For example, orders 1003 and 1006 both have an amount of 3,600. These are two different orders, and both should contribute to the total.

SUM(DISTINCT o.amount) counts 3,600 only once, incorrectly excluding one of the orders.

The problem is not duplicate amounts. It is that the join produces multiple rows for the same order.

Query
-- An attempt to solve using DISTINCT returns wrong result as well.
SELECT SUM(DISTINCT o.amount) AS order_value
FROM orders AS o
INNER JOIN payments AS p
ON p.order_id = o.order_id
WHERE o.status = 'paid';
Result1 row
order_value
9050

The fix: Aggregate payments before joining

Instead of joining individual payments directly to orders, first summarise payments to produce one row per order.

The approach is:

  1. Group payments by order_id and count the payments for each order.
  2. Join the summarised payments with the paid orders.
  3. Calculate the total order value and total payment count.

This ensures each order appears only once in the joined result, while its payment count still includes every payment.

The inner query used in the FROM clause is called a derived table, which we will explore in detail in P10.

Query
SELECT
SUM(o.amount) AS order_value,
SUM(p.payments) AS payments,
SUM(p.collected) AS collected
FROM orders AS o
INNER JOIN (
SELECT
order_id,
COUNT(*) AS payments,
SUM(amount) AS collected
FROM payments
GROUP BY order_id
) AS p
ON p.order_id = o.order_id
WHERE o.status = 'paid';

With this approach, both metrics are correct:

Result1 row
order_valuepaymentscollected
12650612650
Tip
Before aggregating joined data, identify what each row represents. A one-to-many join can repeat values from the “one” side, causing totals to be overstated. When calculating metrics from both sides, aggregate the “many” side to one row per join key before joining.
08

Joining on ranges and time windows

A join condition does not always need to compare two columns for equality. The ON clause can use any condition that determines whether two rows match. Two common examples are range joins, where a value must fall within a defined range, and time-window joins, where an event must occur within a specific period.

Range joins

The sales team wants to classify orders by their amount. They use a lookup table called order_tiers to define three categories:

order_tiers3 rows
tiermin_amountmax_amount
small01500
medium15005000
large5000100000

Unlike the joins we have seen so far, orders and order_tiers do not share a common key. Instead, an order matches a tier when its amount falls within that tier’s range. We can express this condition using BETWEEN.

Pause and predictPredict before you continue

How many rows will this query return?

The query
SELECT
o.order_id,
o.amount,
t.tier
FROM orders AS o
INNER JOIN order_tiers AS t
ON o.amount BETWEEN t.min_amount AND t.max_amount
ORDER BY o.order_id, t.min_amount;

Fixing overlapping ranges

To prevent an amount from matching two tiers, define each range as half-open:

  • Include the minimum value using >=.
  • Exclude the maximum value using <.
Query
SELECT
o.order_id,
o.amount,
t.tier
FROM orders AS o
INNER JOIN order_tiers AS t
ON o.amount >= t.min_amount
AND o.amount < t.max_amount
ORDER BY o.order_id, t.min_amount;

Now, an amount of 1,500 belongs only to medium, because small excludes its upper boundary.

Result7 rows
order_idamounttier
10011500medium
10023200medium
10033600medium
10048500large
1005750small
10063600medium
10073200medium

The result now contains 7 rows, one for each order.

Because each range starts exactly where the previous one ends, every amount from 0 (inclusive) to 100,000 (exclusive) belongs to exactly one tier.

However, range joins can also exclude rows. If an order amount is below 0 or at least 100,000, it will not match any tier and will disappear from an INNER JOIN result. Use a LEFT JOIN instead when every order must appear, even if no tier matches.

Time-window joins

Range conditions are also useful when matching events based on time. Consider a warehouse that records shipments in the shipments table.

shipments5 rows
shipment_idorder_idshipped_date
S110012026-05-03
S210032026-05-06
S310052026-05-07
S410062026-05-08
S510102026-05-08

Let’s say our new requirement is to, show every paid order, including its shipment date if it was shipped within 2 days of the order date. If it was shipped later or has not shipped, show NULL for the shipment date.

Query
SELECT
o.order_id,
o.order_date,
s.shipped_date
FROM orders AS o
LEFT JOIN shipments AS s
ON s.order_id = o.order_id
AND s.shipped_date >= o.order_date
AND s.shipped_date <= o.order_date + INTERVAL '2' DAY
WHERE o.status = 'paid'
ORDER BY o.order_id;
Result5 rows
order_idorder_dateshipped_date
10012026-05-022026-05-03
10032026-05-03NULL
10052026-05-062026-05-07
10062026-05-072026-05-08
10072026-05-08NULL

Why does the time condition belong in ON?

The join needs to check three conditions before attaching a shipment to an order:

  1. The shipment belongs to the same order.
  2. The shipment date is not earlier than the order date.
  3. The shipment date is no more than 2 days after the order date.

All three conditions belong in ON because they determine whether a shipment qualifies as a match.

Consider order 1003. It was placed on May 3 but shipped on May 6, which is 3 days later. The shipment does not satisfy the time-window condition, so it cannot match. However, because this is a LEFT JOIN, order 1003 still appears with a NULL shipment date. Order 1007 also remains, even though it has no matching shipment.

The condition o.status = 'paid' serves a different purpose. It determines which orders should appear in the final result, so it belongs in WHERE.

This follows the same distinction we learned earlier: ON controls matching rows, while WHERE filters the result.

A note on date arithmetic

The expression:

Query
o.order_date + INTERVAL '2' DAY

adds two days to the order date. This syntax works in DuckDB, PostgreSQL and MySQL.

Tip
A join condition is a rule for deciding which pairs of rows match, not necessarily an equality comparison.

For range joins, avoid overlapping boundaries when each value should match only one range. For time-window joins, place the time condition in ON when rows from the left table must remain even without a qualifying match.

Always consider two questions: Can one row match multiple rows? And what happens when no match exists?
09

Chaining joins

Real-world queries often combine three or more tables. The easiest way to understand these queries is to examine one join at a time.

The requirement is to show every customer, their orders, and each order’s shipment date. Customers who have never ordered must still appear, and orders that have not shipped should show NULL for the shipment date.

Here, we need two joins:

  1. Join customers with orders to retrieve each customer’s orders.
  2. Join the result with shipments to retrieve shipment dates.

The first join uses a LEFT JOIN to preserve every customer. But what happens if the second join uses an INNER JOIN?

Pause and predictPredict before you continue

Which customers will appear in the result?

The query
SELECT
c.customer_name,
o.order_id,
s.shipped_date
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
INNER JOIN shipments AS s
ON s.order_id = o.order_id
ORDER BY c.customer_id, o.order_id;

Why did some customers disappear?

To understand the result, follow the joins in the order they are written.

Step 1: LEFT JOIN customers with orders

The first join produces 7 rows, as we saw in section 03. Every customer appears, including Esha, who has no orders. Her order_id is NULL. At this point, the requirement to preserve every customer is satisfied.

Step 2: INNER JOIN the result with shipments

The second join operates on the rows produced by the first join. Because it is an INNER JOIN, it keeps only rows whose order_id matches a record in shipments.

This changes the result:

  • Esha disappears because her order_id is NULL, so it cannot match a shipment.
  • Dev disappears because his only order, 1004, has not shipped.
  • Ben loses order 1002 because it has not shipped, but he still appears through order 1006.
  • Asha and Carla remain because their orders have matching shipment records.

The important point is that the second INNER JOIN removes rows preserved by the first LEFT JOIN.

A LEFT JOIN does not guarantee that its preserved rows will survive later joins. Each subsequent join can change which rows remain.

The fix: Use LEFT JOIN for shipments too

We want every customer to remain, even if they have no orders or their orders have not shipped.

To achieve this, change the second join to a LEFT JOIN:

Query
SELECT
c.customer_name,
o.order_id,
s.shipped_date
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
LEFT JOIN shipments AS s
ON s.order_id = o.order_id
ORDER BY c.customer_id, o.order_id;
Result7 rows
customer_nameorder_idshipped_date
Asha10012026-05-03
Asha10052026-05-07
Ben1002NULL
Ben10062026-05-08
Carla10032026-05-06
Dev1004NULL
EshaNULLNULL

Now both joins preserve unmatched rows:

  • The first LEFT JOIN keeps every customer, even those without orders.
  • The second LEFT JOIN keeps every row from the first join, even when there is no matching shipment.

As a result, Ben’s unshipped order 1002 and Dev’s unshipped order 1004 remain with NULL shipment dates. Esha also remains, with NULL for both order and shipment details. The query now meets the requirement.

Tip
When joining multiple tables, examine what happens to unmatched rows at each step. A later INNER JOIN can remove rows preserved by an earlier LEFT JOIN. If you need to preserve those rows while attaching optional details from another table, use a LEFT JOIN for that relationship too.
10

RIGHT JOIN and FULL JOIN

RIGHT JOIN

A RIGHT JOIN works like a LEFT JOIN, but preserves the rows from the right table instead of the left. It keeps every row from the table written after RIGHT JOIN, even when no matching row exists in the first table.

For example, the following query shows every order, including orders that have not shipped.

Query
SELECT
o.order_id,
o.status,
s.shipment_id
FROM shipments AS s
RIGHT JOIN orders AS o
ON o.order_id = s.order_id
ORDER BY o.order_id;
Result7 rows
order_idstatusshipment_id
1001paidS1
1002pendingNULL
1003paidS2
1004cancelledNULL
1005paidS3
1006paidS4
1007paidNULL

Here, orders is the right table, so every order remains in the result.

Orders 1002, 1004 and 1007 have no matching shipment, so their shipment_id is NULL. Shipment S5 is excluded because it has no matching order, and unmatched rows from the left table are not preserved.

We could produce the same result by writing orders LEFT JOIN shipments. In practice, a LEFT JOIN is often easier to read because it places the table whose rows must be preserved first.

FULL JOIN

A FULL JOIN, also called a FULL OUTER JOIN, preserves unmatched rows from both tables. This is especially useful for reconciliation, where we compare two sources to find records that are missing from either side.

Consider a reconciliation between orders and shipments. Finance wants to identify orders that have not shipped, while the warehouse wants to find shipments that reference orders missing from the orders table.

For example, shipment S5 references order 1010, but order 1010 does not exist in orders. A FULL JOIN lets us examine both types of missing matches in one result.

Pause and predictPredict before you continue

What value will appear in the order_id column for shipment S5?

The query
SELECT
o.order_id,
o.status,
s.shipment_id
FROM orders AS o
FULL JOIN shipments AS s
ON s.order_id = o.order_id
ORDER BY o.order_id;

Showing the correct order ID

For reconciliation, we want to display the order ID regardless of which table contains it. We can use COALESCE to select the first non-NULL value from o.order_id and s.order_id.

We can also use a CASE expression to identify whether each row represents a matched order, an order without a shipment, or a shipment without an order.

Query
SELECT
COALESCE(o.order_id, s.order_id) AS order_id,
o.status,
s.shipment_id,
CASE
WHEN o.order_id IS NULL THEN 'shipment with no order'
WHEN s.order_id IS NULL THEN 'not shipped'
ELSE 'shipped'
END AS reconciliation
FROM orders AS o
FULL JOIN shipments AS s
ON s.order_id = o.order_id
ORDER BY 1;
Result8 rows
order_idstatusshipment_idreconciliation
1001paidS1shipped
1002pendingNULLnot shipped
1003paidS2shipped
1004cancelledNULLnot shipped
1005paidS3shipped
1006paidS4shipped
1007paidNULLnot shipped
1010NULLS5shipment with no order

The result now provides a clear reconciliation status for every record:

  • shipped: The order has a matching shipment.
  • not shipped: The order exists, but no matching shipment was found.
  • shipment with no order: The shipment exists, but its order is missing from orders.

This answers both teams’ questions in one query.

Order 1007 is marked as paid but has no shipment, while shipment S5 references an order that does not exist in the order records. Both may need investigation.

USING instead of ON?

So far, we have used ON to specify which columns should match in a join. When both tables have a column with the same name, SQL also allows us to use USING.

Consider our earlier FULL JOIN:

Query
SELECT
o.order_id,
s.order_id AS shipment_order_id,
s.shipment_id
FROM orders AS o
FULL JOIN shipments AS s
ON o.order_id = s.order_id;

For shipment S5, the two order ID columns contain different values:

order_idshipment_order_idshipment_id
NULL1010S5

This happens because ON keeps the two columns separate. The order ID from orders is NULL because there is no matching order, while the order ID from shipments is 1010.

Now consider the same join written with USING:

Query
SELECT
order_id,
s.shipment_id
FROM orders AS o
FULL JOIN shipments AS s
USING (order_id);

The result for shipment S5 is:

order_idshipment_id
1010S5

What changed?

USING (order_id) tells SQL two things:

  1. Match rows using the order_id column from both tables.
  2. Produce a single combined order_id column when that column is selected without a table prefix.

For a FULL JOIN, the combined column uses the available order ID from either table.

  • If both tables match, their order IDs are equal, so that value is shown.
  • If only orders has a row, its order ID is shown.
  • If only shipments has a row, its order ID is shown.

This is similar to writing COALESCE(o.order_id, s.order_id) when using ON.

Important: USING does not change which rows match or which unmatched rows survive. It only simplifies the equality condition and combines the shared key in the output.

Use USING when the matching columns have the same name and are compared for equality. Use ON when column names differ or when the matching condition requires additional comparisons.

In practice, the ON form is more flexible because it also supports columns with different names and more complex matching conditions.

Database support: PostgreSQL and DuckDB support FULL JOIN. MySQL does not support it directly. In MySQL, we can produce the equivalent result by combining a LEFT JOIN with unmatched rows from the other table using UNION ALL.

Comparing the four join types

All four joins include rows that satisfy the matching condition. The difference is which unmatched rows they preserve.

Join typeMatching rowsUnmatched left rowsUnmatched right rows
INNER JOINKeptRemovedRemoved
LEFT JOINKeptKeptRemoved
RIGHT JOINKeptRemovedKept
FULL JOINKeptKeptKept

When choosing a join, ask yourself: Which rows must remain even when no match exists?

  • Use INNER JOIN when only matching rows matter.
  • Use LEFT JOIN when every row from the left table must remain.
  • Use RIGHT JOIN when every row from the right table must remain.
  • Use FULL JOIN when unmatched rows from both tables must remain.
11

CROSS JOIN and self joins

CROSS JOIN: Every possible combination

The joins we have seen so far use a matching condition to connect related rows. A CROSS JOIN works differently: it combines every row from the first table with every row from the second table. It does not need an ON clause because there is no matching condition.

Consider a business that offers two delivery options:

shipping_methods2 rows
methodfee
Standard0
Express150

The requirement is to show every customer the available delivery options and their fees.

Query
SELECT
c.customer_name,
m.method,
m.fee
FROM customers AS c
CROSS JOIN shipping_methods AS m
ORDER BY c.customer_id, m.fee;
Result10 rows
customer_namemethodfee
AshaStandard0
AshaExpress150
BenStandard0
BenExpress150
CarlaStandard0
CarlaExpress150
DevStandard0
DevExpress150
EshaStandard0
EshaExpress150

The result contains 10 rows: 5 customers × 2 delivery methods.

Each customer appears twice, once for Standard delivery and once for Express delivery. If the business offered three delivery methods, each customer would appear three times, producing 15 rows. This is called a Cartesian product, where every row from one table is paired with every row from another.

When is a CROSS JOIN useful?

A CROSS JOIN is useful when we intentionally need every possible combination. For example, suppose a sales report must show every product for every month, including months when a product had no sales.

We could first use a CROSS JOIN to generate every product-month combination. Then, a LEFT JOIN could attach the actual sales data. For months with no sales, COALESCE could replace the missing total with 0. This ensures the report includes every product and every month, even when no sales occurred.

Watch out for accidental CROSS JOINs

SQL also supports an older syntax where tables are separated by commas in the FROM clause, with matching conditions written in WHERE.

If the matching condition is missing, SQL combines every row from both tables.

For example:

Query
SELECT COUNT(*) AS row_count
FROM customers, orders;
Result1 row
row_count
35

There are 5 customers and 7 orders, so the query produces:

5 × 7 = 35 rows

However, only 6 orders have a matching customer in our data. Instead of those 6 matching pairs, the query creates every possible customer-order combination, including combinations that have no relationship.

When a join returns more rows than expected, check whether the matching condition is missing, incorrect, or allows multiple matches.

Self joins: One table, two roles

So far, we have joined different tables. But SQL also allows a table to be joined with itself. This is called a self join.

A self join is useful when rows in a table refer to other rows in the same table.

Consider the customers table. It contains a column named referred_by_customer_id, which stores the ID of the customer who referred someone. For example, Ben has referred_by_customer_id = 1. Customer 1 is Asha, which means Asha referred Ben.

The requirement is to show every customer alongside the name of the customer who referred them. Customers without a referrer must still appear.

Query
SELECT
c.customer_name AS customer,
ref.customer_name AS referred_by
FROM customers AS c
LEFT JOIN customers AS ref
ON ref.customer_id = c.referred_by_customer_id
ORDER BY c.customer_id;
Result5 rows
customerreferred_by
AshaNULL
BenAsha
CarlaAsha
DevBen
EshaNULL

How does a table join with itself?

Although customers appears twice in the query, there is still only one physical table.

We use two aliases to give it different roles:

  • c represents the customer whose details we want to display.
  • ref represents the referrer, whose name we want to retrieve.

The join condition connects these two roles:

Query
ON ref.customer_id = c.referred_by_customer_id

For Ben, c.referred_by_customer_id is 1. SQL looks for a row where ref.customer_id = 1, finds Asha, and returns her name as referred_by.

The same process applies to every customer. Aliases are important because the same table appears twice. They allow SQL to distinguish which instance of customers each column belongs to.

12

How to spot join problems

The problem with most join mistakes are, they do not produce SQL errors. The query runs successfully, but the result may contain missing rows, duplicate rows, incorrect totals, or unexpected NULL values.

The best way to investigate is to start with what looks wrong in the result, then check which part of the join could have caused it.

Use this table as a quick reference:

What you seeWhat to checkSection
Expected rows are missingAn INNER JOIN may be removing rows without matches.02
A row with a NULL join key is missingA NULL key does not match using =, even when the other key is also NULL.02
Unmatched rows disappear despite using LEFT JOINA condition such as o.status = 'paid' in WHERE may be removing them.04
Rows you wanted to exclude still appear with NULL valuesA condition on the left table was placed in ON instead of WHERE.05
A customer with no orders has a count of 1COUNT(*) is counting the NULL-extended row from a LEFT JOIN.06
A total is higher than expectedJoin fan-out may be repeating values from the “one” side of a one-to-many relationship.07
An order appears twice after a range joinThe ranges may overlap, such as when BETWEEN includes a shared boundary.08
Rows preserved by a LEFT JOIN disappear after another joinA later INNER JOIN may be removing rows without matches.09
An order ID appears as NULL after a FULL JOINThe query may be selecting the key from only one table.10
The result contains far more rows than expectedThe join condition may be missing or may allow multiple matches on either side.11
13

Five questions before writing a join

Before choosing INNER JOIN, LEFT JOIN, or another join type, make sure you understand what the query needs to return.

Answer these five questions first.

1. What should one row in the result represent?
Is it one customer, one order, or one customer-order combination? This determines the starting table and the level of detail in the result.

2. How are the tables related?
Identify the condition that connects their rows. It might be a shared key, a range of values, or a time window. This becomes the ON condition.

3. How many matches can each row have?
Can one customer have multiple orders? Can one order have multiple payments? If a join can produce several matches, be careful when summing values from the other table. You may need to aggregate before joining to avoid counting the same value multiple times.

4. Which rows must remain when there is no match?
Use an INNER JOIN when only matching rows matter, a LEFT JOIN when every row from the starting table must remain, or a FULL JOIN when unmatched rows from both tables must remain.

5. Should a condition control which rows match or which rows appear in the final result?
Put conditions that determine valid matches in ON. Put conditions that filter the resulting rows in WHERE. With outer joins, this distinction is especially important because a filter in WHERE can remove rows the join was meant to preserve.

Applying the five questions

Consider the requirement from section 04:

“Show every customer and any paid orders they have, including customers who have no paid orders.”

Here is how the five questions guide the query:

QuestionAnswerSQL decision
1. What does one row represent?One customer with one paid order, or a customer without a paid order.Start with customers AS c.
2. How are the tables related?Orders belong to customers through customer_id.ON o.customer_id = c.customer_id
3. How many matches are possible?A customer can have multiple paid orders.Multiple rows per customer are expected.
4. Which unmatched rows must remain?Every customer, even without a paid order.LEFT JOIN orders AS o
5. Where should the paid-order condition go?It determines which orders can match a customer.AND o.status = 'paid' in ON

Putting these decisions together gives us:

Query
SELECT
c.customer_name,
o.order_id,
o.status
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status = 'paid'
ORDER BY c.customer_id, o.order_id;

The query now expresses the requirement:

  • Every customer remains because customers is on the left of a LEFT JOIN.
  • Only paid orders can match because the status condition is in ON.
  • Customers with multiple paid orders appear once per order.
  • Customers without paid orders still appear, with NULL order details.
14

Customers with no paid orders

The final requirement is to find customers who have never placed a paid order.

We already know that a LEFT JOIN can preserve customers without matching orders. We also learned in section 04 that filtering the right table in WHERE can remove those customers.

However, there is an important exception. We can use WHERE to find customers who have no matching orders at all.

Pause and predictPredict before you continue

Which customers will this query return?

The query
SELECT c.customer_name
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status = 'paid'
WHERE o.order_id IS NULL
ORDER BY c.customer_name;

Why does WHERE work differently here?

In section 04, we used:

Query
WHERE o.status = 'paid'

For NULL-extended rows, NULL = 'paid' evaluates to UNKNOWN. Since WHERE keeps only rows where the condition is TRUE, customers without paid orders disappeared.

This time, we use:

Query
WHERE o.order_id IS NULL

For those same NULL-extended rows, IS NULL evaluates to TRUE. Instead of removing unmatched customers, it selects them.

The difference is what the condition tests:

  • o.status = 'paid' keeps rows containing paid orders.
  • o.order_id IS NULL keeps rows where no paid order matched.

This pattern is called an anti-join. It finds rows in one table that have no qualifying match in another table.

When using this pattern, test a right-table column that cannot be NULL for a real matched row, usually its primary key.

Here, o.order_id is a reliable choice. Although o.status IS NULL would also work with this particular ON condition, checking a non-nullable key makes the intention clearer and is safer as a general pattern.

We will also learn how to solve this problem using NOT EXISTS, which expresses the requirement directly: find customers for whom no matching paid order exists.

Key idea

Use LEFT JOIN with WHERE right_table.id IS NULL to find rows that have no match. Unlike a condition such as right_table.status = 'paid', the IS NULL test deliberately selects the NULL-extended rows.

15

Summary

A join produces one row for every pair of rows that satisfies its matching condition. Outer joins also preserve unmatched rows, filling the missing columns with NULL.

The most important skill is understanding what each result row represents. Before writing a join, consider how the tables relate, how many matches are possible, and which unmatched rows must remain.

Remember that ON determines which rows match, while WHERE filters the result. Be especially careful with one-to-many joins, where repeated rows can lead to incorrect totals.

Next, P08 turns the anti-join from section 14 into its own pattern: EXISTS and NOT EXISTS, for questions that only need to know whether a matching row exists.

Go deeper

Ask Colearn about this pattern

Not sure why your join is dropping rows or multiplying them? Ask about the concept, or paste a simplified version of your query.

Instant answers, grounded in the same verified material the diagnostic grades against.

3 of 3 free questions left
Why does my LEFT JOIN lose rows when I add a WHERE condition?

A LEFT JOIN preserves every row from the left table, filling the right-table columns with NULL when no match exists.

However, a condition such as WHERE o.status = 'paid' filters the result after the join. For unmatched rows, o.status is NULL, so the comparison evaluates to UNKNOWN. Since WHERE keeps only rows where the condition is TRUE, those rows disappear.

As a result, the query returns only customers with paid orders, effectively behaving like an INNER JOIN restricted to paid orders.

How to fix it: Move o.status = 'paid' into the ON clause when you want to match only paid orders while keeping every customer, including those without paid orders.

Why does my SUM return a higher total after joining another table?

This usually happens because the join produces multiple rows for the same record, a problem called join fan-out.

For example, suppose an order worth 3,600 has two payments. Joining orders with payments produces two rows for that order, each containing the same order amount. When SUM(o.amount) runs, it adds 3,600 twice, incorrectly reporting 7,200.

How to fix it: Check whether the joined table contains multiple rows for the same join key. If it does, aggregate that table to one row per key before joining, or calculate the order total separately.

Avoid SUM(DISTINCT o.amount). It removes duplicate amounts, not duplicate orders. Two different orders worth 3,600 should both contribute to the total.

Should a date-window condition go in ON or WHERE?

It depends on whether the condition should control which rows match or filter which rows appear in the result.

Suppose you want every paid order, along with its shipment date if it shipped within 2 days.

Put the date-window condition in ON. This allows only shipments within the specified period to match. Orders with late shipments or no shipments still appear because of the LEFT JOIN, with NULL shipment details.

If you put the same condition in WHERE, those orders would disappear because their rows do not satisfy the condition.

Rule to remember: Use ON to restrict matching shipments while preserving orders. Use WHERE to filter the orders you actually want to return, such as o.status = 'paid'.

Why does the order ID appear as NULL after a FULL JOIN?

A FULL JOIN preserves unmatched rows from both tables, but it does not automatically combine their key columns when using ON.

Suppose shipment S5 references order 1010, but that order does not exist in orders.

The shipment still appears in the result. However, o.order_id is NULL because there is no matching row in orders. The value 1010 exists in s.order_id, which comes from shipments.

How to fix it: Use COALESCE(o.order_id, s.order_id) to return the available order ID from either table.

Alternatively, use USING (order_id) when both tables share the same column name, then select the combined order_id column without a table prefix.

Don’t paste credentials, personal data, or confidential production records.

Practise this pattern

Now write it yourselfAll 17 Joins problems →

Graded problems on this pattern, easy to hard. They run in your browser against real tables – no sign-up needed.

Related patterns

Up next · Pattern 8EXISTS & anti-joins

Next, we build on joins to find rows that do or do not have related matches with EXISTS, NOT EXISTS, and semi- and anti-join patterns.