SQL JOINs in MySQL
A JOIN combines rows from two or more tables based on a related column. JOINs are the cornerstone of relational databases — they let you store data in normalized, non-redundant tables and reassemble it into meaningful result sets at query time. This page gives you the foundation; separate pages cover each JOIN type in depth.
The Relational Model Foundation
Relational databases store data in separate tables to eliminate redundancy. A customer's email is
stored once in customers, not duplicated on every order row. When you need both customer
information and order data in one result, a JOIN reconnects them using the shared key
(customer_id).
Without JOINs, you would need multiple separate queries and application-level data merging — which is slower, more error-prone, and harder to maintain.
-- The shared key connects these two tables CREATE TABLE customers ( customer_id INT PRIMARY KEY AUTO_INCREMENT, first_name VARCHAR(50) NOT NULL, last_name VARCHAR(50) NOT NULL, email VARCHAR(100) UNIQUE NOT NULL, country VARCHAR(50), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( order_id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL, total DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ); -- JOIN reconnects them: read customer details alongside order details SELECT c.first_name, c.email, o.order_id, o.total FROM customers AS c JOIN orders AS o ON c.customer_id = o.customer_id;
JOIN Types Overview
JOIN Type | Returns | Typical Use Case |
|---|---|---|
INNER JOIN | Only rows with a match in both tables | Fetch related data that is guaranteed to exist |
LEFT JOIN | All left-table rows; NULLs for right table when no match | Include left rows even when related data is missing |
RIGHT JOIN | All right-table rows; NULLs for left table when no match | Rarely used — rewrite as LEFT JOIN with tables swapped |
CROSS JOIN | Every combination of rows (cartesian product) | Generating all combinations, calendar tables, test data |
SELF JOIN | Table joined to itself with two aliases | Hierarchies, comparing rows within the same table |
Visual Analogy — Set Diagrams
Imagine two overlapping circles. The left circle represents table A and the right circle represents table B. The overlapping center contains rows that exist in both.
- INNER JOIN — returns only the overlap (rows present in both tables).
- LEFT JOIN — returns the entire left circle (all left rows, overlap included).
- RIGHT JOIN — returns the entire right circle (all right rows, overlap included).
- FULL OUTER JOIN — returns both circles entirely. MySQL does not support this natively;
emulate it with
UNIONof LEFT JOIN and RIGHT JOIN. - CROSS JOIN — returns every row in A paired with every row in B; the set-diagram analogy does not apply here.
JOIN Syntax: ON vs USING
MySQL provides two ways to specify the join condition.
ON— explicit condition; works regardless of column naming; supports complex conditions.USING— shorthand when the join column has the same name in both tables; cleaner for simple foreign-key joins.
-- ON clause: always works, explicitly names both columns SELECT c.first_name, o.order_id, o.total FROM customers AS c JOIN orders AS o ON c.customer_id = o.customer_id; -- USING clause: shorthand when column names match in both tables SELECT c.first_name, o.order_id, o.total FROM customers AS c JOIN orders AS o USING (customer_id); -- The USING column (customer_id) appears only once in the result set -- USING with multiple columns (composite key) SELECT * FROM invoices AS i JOIN invoice_lines AS il USING (company_id, invoice_id); -- ON with a compound condition SELECT * FROM employees AS e JOIN salaries AS s ON e.emp_no = s.emp_no AND s.to_date = '9999-01-01'; -- only current salary
USING (col), the joined column appears only once in SELECT * output, whereas ON a.col = b.col includes it twice. This matters when you do SELECT * but rarely in production queries that list columns explicitly.Your First JOIN Query
-- List every delivered order alongside the customer's details SELECT c.first_name, c.last_name, c.email, o.order_id, o.total, o.created_at AS order_date FROM customers AS c JOIN orders AS o ON c.customer_id = o.customer_id WHERE o.status = 'delivered' ORDER BY o.created_at DESC LIMIT 20;
Joining Three or More Tables
Chain as many JOINs as needed. Each join adds columns from another table. MySQL evaluates them left to right, but the optimizer may reorder them for efficiency.
-- Four-table join: customers → orders → order_items → products → categories SELECT c.email, o.order_id, o.created_at AS order_date, cat.name AS category, p.name AS product, oi.quantity, oi.unit_price, ROUND(oi.quantity * oi.unit_price, 2) AS line_total FROM customers AS c JOIN orders AS o ON c.customer_id = o.customer_id JOIN order_items AS oi ON o.order_id = oi.order_id JOIN products AS p ON oi.product_id = p.product_id JOIN categories AS cat ON p.category_id = cat.category_id WHERE o.status = 'delivered' AND c.country = 'Canada' ORDER BY o.order_id, cat.name, p.name;
JOIN Performance Fundamentals
MySQL uses a nested-loop join algorithm: for each row in the outer (driving) table, it looks up matching rows in the inner table. Without an index on the join column of the inner table, MySQL performs a full table scan for every row in the outer table — O(n × m) complexity.
The two most impactful things you can do for JOIN performance:
- Index every foreign key column in referencing tables.
- Filter early with WHERE — narrow the driving table before the join.
-- Check whether indexes exist on your join columns SHOW INDEX FROM orders; -- look for an index on customer_id SHOW INDEX FROM order_items; -- look for indexes on order_id and product_id -- Create missing indexes ALTER TABLE orders ADD INDEX idx_customer_id (customer_id); ALTER TABLE order_items ADD INDEX idx_order_id (order_id); ALTER TABLE order_items ADD INDEX idx_product_id (product_id); -- EXPLAIN reveals whether the optimizer is using your indexes EXPLAIN SELECT c.email, o.total FROM customers AS c JOIN orders AS o ON c.customer_id = o.customer_id WHERE c.country = 'Canada'; -- Look for: type=ref and key column referencing the index
Common JOIN Pitfall — Accidental Cartesian Product
Forgetting the ON condition (or writing one that is always true) creates a cartesian product:
every left row paired with every right row. With 1,000 customers and 5,000 orders, that is
5,000,000 result rows — not the 5,000 you wanted.
-- DANGEROUS: missing ON creates a cartesian product SELECT c.first_name, o.order_id FROM customers AS c JOIN orders AS o; -- No ON clause -- Returns: 1,000 × 5,000 = 5,000,000 rows -- Safe: always include ON or USING SELECT c.first_name, o.order_id FROM customers AS c JOIN orders AS o ON c.customer_id = o.customer_id; -- Returns: exactly the number of matching pairs
EXPLAIN to inspect the estimated row count. A cartesian product on a 100k-row table can generate 10 billion rows and bring down a production server.Implicit JOIN Syntax (Avoid in New Code)
Older SQL code uses commas in FROM with the join condition in WHERE. This implicit syntax
still works but is not recommended — it is easy to accidentally create a cartesian product by
omitting the WHERE join condition.
-- Old implicit JOIN (legacy code — avoid writing new queries this way) SELECT c.first_name, o.order_id FROM customers AS c, orders AS o WHERE c.customer_id = o.customer_id AND o.status = 'delivered'; -- Modern explicit JOIN (preferred) SELECT c.first_name, o.order_id FROM customers AS c JOIN orders AS o ON c.customer_id = o.customer_id WHERE o.status = 'delivered';
Mixing JOIN Types in One Query
-- INNER JOIN customers to orders, LEFT JOIN to optional shipping info
SELECT
c.first_name,
o.order_id,
o.total,
sh.carrier,
sh.tracking_number
FROM customers AS c
JOIN orders AS o ON c.customer_id = o.customer_id
LEFT JOIN shipments AS sh ON o.order_id = sh.order_id -- optional
WHERE o.status IN ('shipped', 'delivered')
ORDER BY o.created_at DESC;Full E-commerce Order Receipt Example
-- Complete order receipt: customer, order, items, products, categories
SELECT
CONCAT(c.first_name, ' ', c.last_name) AS customer_name,
c.email,
o.order_id,
DATE(o.created_at) AS order_date,
o.status,
cat.name AS category,
p.name AS product,
oi.quantity,
oi.unit_price,
ROUND(oi.quantity * oi.unit_price, 2) AS line_total,
ROUND(
SUM(oi.quantity * oi.unit_price)
OVER (PARTITION BY o.order_id), 2
) AS order_total
FROM customers AS c
JOIN orders AS o ON c.customer_id = o.customer_id
JOIN order_items AS oi ON o.order_id = oi.order_id
JOIN products AS p ON oi.product_id = p.product_id
JOIN categories AS cat ON p.category_id = cat.category_id
WHERE o.order_id = 12345
ORDER BY cat.name, p.name;Choosing the Right JOIN Type
Question to ask | Use this JOIN |
|---|---|
I only want rows that have a match in both tables | INNER JOIN |
I want all rows from the first table, even those without a match | LEFT JOIN |
I want to find rows in the first table that have NO match in the second | LEFT JOIN + WHERE right.pk IS NULL |
I want to generate every possible combination of two sets | CROSS JOIN |
I need to compare a row to other rows in the same table | SELF JOIN |
Always write explicit JOIN ... ON syntax — never rely on comma-separated FROM tables
Index every foreign key column used in JOIN conditions
Use EXPLAIN before running a new JOIN on large tables to estimate row counts
Filter with WHERE early to reduce the driving table before joining
Prefer LEFT JOIN over RIGHT JOIN for consistency — always put the anchor table on the left
Read the dedicated pages for INNER JOIN, LEFT/RIGHT JOIN, CROSS JOIN, and SELF JOIN for deeper coverage