Part · Chapter

Joins

A join puts the split-up tables back together, and the join type decides what happens to rows with no partner.

The short version

  • A join pairs rows from two tables. Almost always foreign key equals primary key.
  • INNER keeps only the pairs. LEFT, RIGHT and FULL also keep unmatched rows, padded with NULL.
  • MySQL has no FULL OUTER JOIN. Emulate it with LEFT JOIN, UNION, RIGHT JOIN.
  • Filter the optional side in ON. In WHERE it turns a LEFT JOIN into an INNER JOIN.
  • NOT IN breaks on NULLs. One NULL gives zero rows; use NOT EXISTS.
  • Joins fail in two ways. No index means slow; fan-out means inflated totals.

Why joins exist

Almost every useful question spans two tables or more (see Designing good tables). A join pairs rows wherever a condition is true, nearly always foreign key equals primary key. Dev has no order; order 13 has no customer.

customers
idnamecity
1AmiraDubai
2BenCairo
3ChloéNULL
4DevRiyadh
orders
idcustomer_idorder_datestatustotal
1012026-01-05paid65.00
1112026-02-11paid180.00
1222026-02-20pending40.00
13NULL2026-03-01paid20.00
1432026-03-15cancelled45.00
customers (left table) orders (right table) 1 · Amira · Dubai 2 · Ben · Cairo 3 · Chloé · NULL 4 · Dev · Riyadh no order: only LEFT and FULL keep Dev 10 · customer 1 · paid 11 · customer 1 · paid 12 · customer 2 · pending 13 · customer NULL · paid 14 · customer 3 · cancelled no customer: only RIGHT and FULL keep 13 orders.customer_id = customers.id
INNER keeps the pairs. LEFT adds Dev, RIGHT adds order 13, FULL both.

Remember

INNER keeps only the pairs. LEFT, RIGHT and FULL also keep partnerless rows, padded with NULLs.

The join family

Every join has the shape FROM a JOIN b ON condition; only the word before JOIN changes.

Join typeWhat it returnsMySQL 8.4 / 9.7 / 26.x
INNER JOINThe matching pairs onlyYes
LEFT JOINEvery left row, NULLs where no partnerYes
RIGHT JOINEvery right row, NULLs where no partnerYes
FULL OUTER JOINUnmatched rows from both sidesNo (LEFT JOIN UNION RIGHT JOIN)
CROSS JOINEvery combination, no ON clauseYes
Self joinA table joined to itself through aliasesYes

INNER JOIN: pairs only

Plain JOIN means INNER JOIN in both engines: matching pairs and nothing else. Dev and order 13 both vanish.

Illustration · Both
SELECT c.name, o.id AS order_id, o.status, o.total
FROM customers AS c
INNER JOIN orders AS o ON o.customer_id = c.id;
nameorder_idstatustotal
Amira10paid65.00
Amira11paid180.00
Ben12pending40.00
Chloé14cancelled45.00

LEFT JOIN: keep everything on the left

A LEFT JOIN (OUTER is optional) keeps every row of the first table, with NULLs where no partner was found. It answers "including those with none".

Illustration · Both
SELECT c.name, o.id AS order_id, o.status, o.total
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id;
nameorder_idstatustotal
Amira10paid65.00
Amira11paid180.00
Ben12pending40.00
Chloé14cancelled45.00
DevNULLNULLNULL

RIGHT JOIN: the mirror

A RIGHT JOIN keeps every row of the second table. Most teams avoid it, since a RIGHT JOIN b is b LEFT JOIN a backwards.

Illustration · Both
SELECT c.name, o.id AS order_id, o.status, o.total
FROM customers AS c
RIGHT JOIN orders AS o ON o.customer_id = c.id;
nameorder_idstatustotal
Amira10paid65.00
Amira11paid180.00
Ben12pending40.00
NULL13paid20.00
Chloé14cancelled45.00

FULL OUTER JOIN: keep both sides

A FULL OUTER JOIN keeps unmatched rows from both tables, which reconciliation needs. PostgreSQL has it; MySQL has no FULL OUTER JOIN in any version (8.4, 9.7 or 26.x), so glue LEFT and RIGHT JOIN with UNION (MySQL vs PostgreSQL).

Illustration · PostgreSQL
SELECT c.name, o.id AS order_id
FROM customers AS c
FULL OUTER JOIN orders AS o ON o.customer_id = c.id;
Illustration · MySQL
-- No FULL OUTER JOIN in MySQL: LEFT JOIN, then RIGHT JOIN, joined by UNION
SELECT c.name, o.id AS order_id
FROM customers AS c LEFT JOIN orders AS o ON o.customer_id = c.id
UNION
SELECT c.name, o.id
FROM customers AS c RIGHT JOIN orders AS o ON o.customer_id = c.id;
nameorder_id
Amira10
Amira11
Ben12
Chloé14
DevNULL
NULL13

UNION drops the shared rows. If duplicates are legitimate, use UNION ALL plus WHERE c.id IS NULL on the RIGHT JOIN half.

CROSS JOIN: every combination

A CROSS JOIN has no ON clause: every left row pairs with every right row. It builds grids, like every customer against every product.

Illustration · Both
SELECT c.name AS customer, p.name AS product
FROM customers AS c
CROSS JOIN products AS p;
customerproduct
AmiraKeyboard
AmiraMouse
AmiraMonitor
BenKeyboard
BenMouse
BenMonitor
ChloéKeyboard
ChloéMouse
ChloéMonitor
DevKeyboard
DevMouse
DevMonitor

Self join

Two aliases and a table joins to itself, as with employees.manager_id. Here b.id > a.id lists each pair of a customer's orders once.

Illustration · Both
SELECT a.customer_id, a.id AS first_order, b.id AS later_order
FROM orders AS a
JOIN orders AS b ON b.customer_id = a.customer_id AND b.id > a.id;
customer_idfirst_orderlater_order
11011

Semi-joins and anti-joins

Sometimes you filter by whether a partner exists, without returning it. An INNER JOIN lists Amira twice; EXISTS returns each customer once, and so does IN with a subquery; the optimizer calls both a semi-join.

Illustration · Both
SELECT c.name
FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id);

-- Same rows:
SELECT name FROM customers WHERE id IN (SELECT customer_id FROM orders);
name
Amira
Ben
Chloé

Anti-join: has none

NOT EXISTS is the direct spelling. The older idiom is a LEFT JOIN plus WHERE o.id IS NULL: keep everyone, then keep the NULL rows.

Illustration · Both
SELECT c.name
FROM customers AS c
WHERE NOT EXISTS (SELECT 1 FROM orders AS o WHERE o.customer_id = c.id);

-- The older idiom, same result:
SELECT c.name
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.id IS NULL;
name
Dev

The NOT IN trap

Illustration · Both
SELECT name
FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
-- Returns 0 rows. No error. Dev is not listed.

The subquery returns 1, 1, 2, NULL, 3, and 4 NOT IN (1, 1, 2, NULL, 3) includes 4 = NULL, which is unknown, so WHERE keeps nothing. Any nullable foreign key does this. Use NOT EXISTS instead.

Careful

NOT IN (subquery) returns nothing as soon as the subquery holds one NULL, silently. Use NOT EXISTS.

Joining four tables at once

Joins chain: each JOIN adds one table, and its ON clause connects it to a table already there.

Illustration · Both
SELECT c.name AS customer, o.id AS order_id, p.name AS product, oi.qty
FROM customers AS c
JOIN orders      AS o  ON o.customer_id = c.id
JOIN order_items AS oi ON oi.order_id   = o.id
JOIN products    AS p  ON p.id          = oi.product_id
ORDER BY c.name, o.id;
customerorder_idproductqty
Amira10Keyboard1
Amira10Mouse1
Amira11Monitor1
Ben12Mouse2
Chloé14Keyboard1

Amira appears once per item line. Order 13's mouse is missing because the first join is an INNER JOIN; a LEFT JOIN from orders brings it back.

ON or WHERE: where the filter goes changes the answer

With an INNER JOIN, ON and WHERE give the same rows. With an outer join they differ, which is the most common reporting bug. You want every customer with their paid orders, Dev included.

Illustration · Both
-- A: the filter in WHERE
SELECT c.name, o.id AS order_id
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.status = 'paid';

-- B: the filter in ON
SELECT c.name, o.id AS order_id
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id AND o.status = 'paid';
A: WHERE o.status = 'paid'
nameorder_id
Amira10
Amira11
B: ON ... AND o.status = 'paid'
nameorder_id
Amira10
Amira11
BenNULL
ChloéNULL
DevNULL

In A, WHERE tested NULL = 'paid' for Dev, which is unknown, and dropped him; Ben and Chloé went too. The LEFT JOIN became an INNER JOIN. In B the condition sits in ON, which only decides which orders may pair.

How the engine joins

The optimizer picks the method and names it in the plan (Indexes and query performance). A nested loop looks up each row's partners in the other table: cheap through an index, terrible without one. Hash and merge joins read each input once instead.

Why join columns need an index, and who creates it

Join columns are usually foreign keys, looked up on every join and every ON DELETE check. MySQL InnoDB requires an index there and creates one if missing. PostgreSQL leaves it to you, and calls an index there "often a good idea".

CapabilityMySQL 8.4 / 9.7 / 26.xPostgreSQL 17 / 18
Nested loop joinYesYes
Hash joinYes (since 8.0.18, default)Yes
Merge joinNoYes
Foreign key column indexed automaticallyYes (InnoDB)No (add it yourself)

The slow join: what a DBA looks for

A missing index on the join column. The symptom is a full scan of a big table on the inner side of a nested loop. EXPLAIN ANALYZE shows rows examined; thousands of times the rows returned means a missing index.

Fan-out. A join to the "many" side multiplies rows, so totals from the "one" side come out too big.

Illustration · Both
SELECT c.name,
       COUNT(*)             AS joined_rows,
       COUNT(DISTINCT o.id) AS orders,
       SUM(o.total)         AS spent
FROM customers AS c
JOIN orders      AS o  ON o.customer_id = c.id
JOIN order_items AS oi ON oi.order_id = o.id
GROUP BY c.name;
namejoined_rowsordersspenttrue total
Amira32310.00245.00
Ben1140.0040.00
Chloé1145.0045.00

Order 10 has two item lines, so its 65.00 is added twice: 245.00 becomes 310.00. When COUNT(*) and COUNT(DISTINCT key) disagree, the join has fanned out; aggregate the many side first (Aggregation and window functions).

For you as a DBA

Slow join: EXPLAIN ANALYZE, then check the join column is indexed. Wrong numbers: compare COUNT(*) with COUNT(DISTINCT key); a mismatch means fan-out.

Ask the AI

Name the engine, the tables, and the rows you expect to keep. "MySQL 8.4: every customer with the count and total of their paid orders, keeping those with none at 0. ON or WHERE for the status filter?"

Further reading

Practice beats reading; see Practice sites and working with AI.

Quick check

1. You run customers LEFT JOIN orders ON o.customer_id = c.id and add WHERE o.status = 'paid'. On the shop data, what comes back?

The LEFT JOIN produces NULL status for Dev, and pending or cancelled for Ben and Chloé. The WHERE test fails for all three, so only Amira's rows survive. Put the status condition in ON to keep every customer. Reread "ON or WHERE: where the filter goes changes the answer".

2. SELECT name FROM customers WHERE id NOT IN (SELECT customer_id FROM orders) returns zero rows on the shop database, although Dev has never ordered. Why?

4 NOT IN (1, 1, 2, NULL, 3) contains the comparison 4 = NULL, which is unknown, so the whole condition is unknown and the row is dropped. Duplicates are harmless. Use NOT EXISTS, or exclude NULLs inside the subquery. Reread "The NOT IN trap".

3. MySQL has no FULL OUTER JOIN. What is the standard way to get the same rows (Dev with no order and order 13 with no customer, in one result)?

LEFT JOIN keeps Dev, RIGHT JOIN keeps order 13, and UNION merges the two results while dropping the rows they share. No sql_mode adds the syntax, and RIGHT JOIN alone loses Dev. Reread "FULL OUTER JOIN: keep both sides".

4. A report joins customers to orders to order_items and sums orders.total per customer. It says Amira spent 310.00, although her two orders total 245.00. What happened?

Joining to the "many" side (order_items) multiplies the order row by its item count, and SUM(o.total) adds 65.00 twice. Join methods never change results, and DECIMAL stays exact. Aggregate the items first or compare COUNT(*) with COUNT(DISTINCT o.id) to see the fan-out. Reread "The slow join: what a DBA looks for".

5. A developer adds a foreign key to a new PostgreSQL 18 table and assumes joins on that column will be fast, "like on our MySQL servers". What should you tell them?

InnoDB requires and auto-creates an index on the referencing column. PostgreSQL only indexes the referenced primary key and leaves the referencing side to you, which is why its manual calls an index there "often a good idea". Reread "Why join columns need an index, and who creates it".