Readineer
PATTERN 7SQL Patterns / Combining Data

SQL JOINs: combining related tables correctly

Learn how to combine related data with INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN, CROSS JOIN, and self joins, and avoid silently losing or multiplying rows.

8 min read12 sections2 predictions
01Foundation

Why do we need JOINs?

Relational databases usually store different kinds of information in separate tables. Customer information might be stored in a customers table, and orders stored separately:

Customers
customer_idcustomer_namecitycountryreferred_by_customer_id
1AshaBengaluruIndiaNULL
2BenLondonUK1
3CarlaDubaiUAE1
4DevBengaluruIndia2
5EshaSingaporeSingaporeNULL
Orders
order_idcustomer_idproductquantityunit_pricestatus
10011Wireless Mouse2750paid
10022Mechanical Keyboard13200pending
10033Laptop Sleeve31200paid
10044Office Chair18500cancelled
10051Wireless Mouse1750paid
10062Monitor Stand21800paid

The customers table tells us who the customer is and where they live; the orders table tells us what they ordered and the order status. The connection between the tables is customers.customer_id to orders.customer_id. For example, orders.customer_id = 1 refers to customers.customer_id = 1, which is Asha.

Key idea
A JOIN allows us to follow that relationship and return information from both tables in the same result.
02Keeping matches

INNER JOIN: return rows that match

Suppose the requirement is “show every order together with the customer’s name and country.” The order information comes from orders, the customer information from 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;
Result
order_idcustomer_namecountryproductstatus
1001AshaIndiaWireless Mousepaid
1002BenUKMechanical Keyboardpending
1003CarlaUAELaptop Sleevepaid
1004DevIndiaOffice Chaircancelled
1005AshaIndiaWireless Mousepaid
1006BenUKMonitor Standpaid

The important part is ON c.customer_id = o.customer_id, which tells SQL how rows from the two tables are related. You can read the query as: for each order, find the customer whose customer_id matches the order’s customer_id.

INNER JOIN returns only rows for which a match exists on both sides. Esha exists in customers (customer_id = 5), but there is no order with customer_id = 5, so Esha does not appear in the INNER JOIN result. Table aliases (orders → o, customers → c) keep the query readable and, when both tables contain a customer_id, make it clear which column comes from which table.

Key idea
A JOIN produces rows based on matches, not based on the number of rows that existed on one side before the join. Asha has one row in customers but two orders, so after joining, Asha appears twice. This is not accidental duplication; it represents a one-to-many relationship, and it becomes very important when you later count or aggregate joined data.
03Keeping unmatched rows

LEFT JOIN: keep every row from the left table

Now consider a different requirement: “show every customer and any orders they have placed, including customers who have never ordered.” An INNER JOIN will not work because Esha has no matching order and would disappear. Use LEFT JOIN:

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;
Result
customer_idcustomer_nameorder_idproductstatus
1Asha1001Wireless Mousepaid
1Asha1005Wireless Mousepaid
2Ben1002Mechanical Keyboardpending
2Ben1006Monitor Standpaid
3Carla1003Laptop Sleevepaid
4Dev1004Office Chaircancelled
5EshaNULLNULLNULL

Esha is preserved because she belongs to the left table. There is no matching order, so SQL fills the order columns with NULL. You can read LEFT JOIN as: keep every row from the left table, and add matching information from the right table when it exists.

Direction matters. FROM customers LEFT JOIN orders preserves every customer, while FROM orders LEFT JOIN customers preserves every order. So before choosing LEFT JOIN, ask: which table contains the rows that must never disappear? Place that table on the left.

04The featured failure

The LEFT JOIN that quietly loses unmatched rows

Suppose the requirement is: show every customer and any paid orders they have; customers without a paid order should still appear. A natural query uses LEFT JOIN to preserve customers, then WHERE o.status = 'paid' to keep paid orders. At first glance, this seems reasonable. But the result does not preserve every customer.

PAUSE & PREDICTWhich customers disappear?
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;
Your prediction
05The fix

Put the right-table filter in ON

The requirement is “preserve every customer, but match only paid orders.” That condition belongs in the join itself:

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;
Result
customer_nameorder_idstatus
Asha1001paid
Asha1005paid
Ben1006paid
Carla1003paid
DevNULLNULL
EshaNULLNULL

Now o.status = 'paid' controls which order rows are allowed to match. It does not decide whether the customer survives.

Key idea
ON controls which rows match during the join. WHERE filters the result after the join. For outer joins, this distinction can completely change the result.

A practical rule for LEFT JOIN filters

When using a LEFT JOIN, ask: is this condition part of deciding which right-side rows should match, or should it remove completed result rows? “Keep all customers, but match only paid orders” goes in ON. “Return only customers whose joined order is paid” may be exactly what WHERE o.status = 'paid' is for. The placement should follow the business requirement.

06The rest of the family

RIGHT, FULL, and choosing a join

RIGHT JOIN is the mirror image of LEFT JOIN: it keeps every row from the right table. FROM orders AS o RIGHT JOIN customers AS c ON … preserves every customer, so Esha appears even though she has no order. This produces the same general result as putting customers on the left of a LEFT JOIN. For readability, programmers prefer LEFT JOIN and simply arrange the tables so the rows that must be preserved are on the left. You still need to understand RIGHT JOIN when reading existing SQL.

FULL JOIN, often written FULL OUTER JOIN, preserves rows from both sides: matching rows are combined, unmatched left rows are kept, and unmatched right rows are kept. It is useful for reconciliation problems such as “which records exist in both datasets, which only on the left, and which only on the right?” Database support for FULL JOIN can vary, so check the syntax available in the system you are using.

comparing the main JOIN types (Customers ↔ Orders)
JoinMatching rowsUnmatched customersUnmatched orders
INNER JOINKeepRemoveRemove
LEFT JOINKeepKeepRemove
RIGHT JOINKeepRemoveKeep
FULL JOINKeepKeepKeep
Key idea
The easiest way to choose a join is not by memorising this table. Instead ask: which unmatched rows must still appear?
07Every combination

CROSS JOIN: every combination of rows

Most joins connect rows based on a relationship. CROSS JOIN is different: it creates every possible combination between two input sets. Suppose we have a shipping_methods table with Standard, Express, and Same Day. Then SELECT c.customer_name, s.shipping_method FROM customers AS c CROSS JOIN shipping_methods AS s produces, for Asha, Asha | Standard, Asha | Express, Asha | Same Day, and the same three combinations for every other customer. There are 5 customers × 3 shipping methods = 15 rows. No ON condition is used because every row is matched with every row.

CROSS JOIN can be intentional, for example generating every customer and shipping-method combination, or every product and month, or every region and reporting period. But it can also produce very large results: 1,000 rows crossed with 10,000 rows produces 10,000,000 combinations. So always ask whether every possible pair is actually required.

A missing join condition can multiply rows. If you intend to match customers with their orders but accidentally produce every combination, 5 customers × 6 orders = 30 rows appear instead of the expected six matched order rows. This is sometimes called a Cartesian product. Unexpected row multiplication is one of the first things to check when a join returns far more rows than expected: ask what the join condition is, and how many matches each row can have.
08Roles and grain

Self joins, row counts, and more tables

A join does not require two different tables. Sometimes rows in one table refer to other rows in the same table. The referred_by_customer_id column points to another row in the same customers table: Ben.referred_by_customer_id = 1, and customer 1 is Asha, so Asha referred Ben. To show each customer together with the person who referred them, we can join customers to itself:

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;
Result
customerreferred_by
AshaNULL
BenAsha
CarlaAsha
DevBen
EshaNULL

There is still only one physical customers table. The aliases let it play two logical roles: c is the customer, ref is the referring customer. This is called a self join. Without aliases, customers.customer_id would be ambiguous because customers appears twice. Self joins are commonly used with data such as employees and managers, categories and parent categories, or accounts and parent accounts.

JOINs can add rows correctly

FROM customers LEFT JOIN orders over five customers does not necessarily return five rows. Asha matches two orders, Ben two, Carla one, Dev one, and Esha none but is preserved by LEFT JOIN, so the result contains 2 + 2 + 1 + 1 + 1 = 7 rows. This is correct because of the one-to-many relationship.

The important question is not “did my join create duplicates?” Ask “how many matches should each row have according to the relationship?” Repeated customer information may be completely correct; unexpected repeated business entities may indicate the join key or relationship has been misunderstood.

Joining more than two tables

Real queries often combine several related tables. If product information is stored in a products table and orders stores the corresponding product_id, we can combine customers, orders, and products with two joins, one per relationship. A query with several joins is easier to reason about when each relationship is checked individually: which customer owns this order, and which product does this order contain.

09Recognition cues

How to spot join problems

Information required by the query lives in different tables, or two tables share an identifier such as customer_id.
Rows unexpectedly disappear after changing an INNER JOIN.
A LEFT JOIN still fails to return unmatched left-side rows.
A condition on the right table appears in WHERE.
The number of rows increases after joining, or a query produces far more rows than expected.
One row on one side can legitimately match several rows on the other.
The same table needs to play two roles, such as employee and manager.
NULL appears in columns from an outer-joined table.

When results look wrong, inspect: which rows should be preserved? What defines a match? How many matches can each row have? Are filters in ON or WHERE intentionally?

10A practical mental model

Answer four questions before writing a join

1 · What is the starting table?

“Show every customer” → FROM customers AS c.

2 · Which additional information is needed?

Orders → JOIN orders AS o.

3 · How are the rows related?

ON o.customer_id = c.customer_id.

4 · What should happen when there is no match?

If customers without orders must remain, LEFT JOIN.

Key idea
Do not begin by asking “should I use INNER or LEFT?” Begin with “which rows must survive if there is no match?” The join type usually follows naturally.
11Check your understanding

One more prediction

Requirement: return every customer and any paid orders they have; customers without a paid order must still appear. Which query correctly expresses the requirement?

customers
customer_idcustomer
1Asha
2Ben
3Carla
4Dev
5Esha
orders
order_idcustomer_idstatus
10011paid
10022pending
10033paid
10044cancelled
10051paid
10062paid
ONE MORE PREDICTIONWhich query correctly expresses the requirement?
Query
-- Option A
FROM customers c
INNER JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'paid'

-- Option B
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'paid'

-- Option C
FROM customers c
LEFT JOIN orders o
    ON o.customer_id = c.customer_id
   AND o.status = 'paid'

-- Option D
FROM customers c
CROSS JOIN orders o
WHERE o.status = 'paid'
Your prediction
12Summary

Summary

A JOIN combines rows based on relationships between tables. Use INNER JOIN when only matching rows should remain, LEFT JOIN when every row from the left table must remain, RIGHT JOIN when every row from the right table must remain, FULL JOIN when unmatched rows from both sides must remain, and CROSS JOIN when every possible combination is intentionally required. A self join lets one table play multiple roles.

Key idea
The most important join question is not the join name. It is: which rows must survive when there is no match? Then: what defines a correct match, and how many matches can each row legitimately have? Finally, remember the most important LEFT JOIN trap: a right-table filter in WHERE can remove customers without paid orders. To keep every customer while matching only paid orders, write the condition in ON. ON controls matching; WHERE filters the joined result.

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

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.

Ask Colearn

3 of 3 free questions left

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

Why did my LEFT JOIN drop the rows that had no match?

A filter on the right table in WHERE runs after the join, so unmatched left rows (which have NULLs on the right) fail the condition and disappear. The LEFT JOIN effectively becomes an INNER JOIN.

Move the right-table condition into the ON clause so it controls matching instead of removing rows.

from the unit →
Should a filter on the right table go in ON or WHERE?

ON decides which right-side rows are allowed to match (unmatched left rows stay, with NULLs). WHERE filters the joined result afterwards, which can remove those preserved rows.

For a LEFT JOIN, put 'keep the left row but only match paid orders' in ON; put 'only return rows whose order is paid' in WHERE.

from the unit →
Why do rows with a NULL join key disappear from my join?

A join matches on equality, and NULL is never equal to anything, so a row whose join key is NULL matches nothing and drops from an inner join.

Decide whether those rows should be preserved (an outer join) and whether a NULL key is even valid for the relationship.

from the unit →
How do I show each customer and who referred them from one table?

Join the table to itself with two aliases: FROM customers AS c LEFT JOIN customers AS ref ON ref.customer_id = c.referred_by_customer_id.

The aliases let one physical table play two roles, the customer and the referrer; the same shape handles employees/managers and parent/child hierarchies.

from the unit →

That’s the free taste

Two ways to go deeper.

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

Related patterns

P02Pattern · 8 minNULL & UNKNOWNWhy NULL on an outer-joined column is UNKNOWN, and how WHERE drops it.P04Pattern · 8 minAggregation & GROUP BYWhy a one-to-many join fans out rows and can double a SUM.P08Pattern · 7 minEXISTS & anti-joinsWhen you only need to know whether a related row exists, not its columns.
Up next · Pattern 8
EXISTS & 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.

Continue to P8 →Browse all patterns