Can you write this from memory?
Write a query to join the orders table with the users table on user_id, selecting all columns.
A common SQL JOIN bug: you write a LEFT JOIN to keep all customers, add a WHERE clause on the orders table, and silently convert it back to an INNER JOIN. Half your customers vanish and nobody notices until production.
ON conditions run during the join. WHERE conditions run after. A LEFT JOIN with a WHERE filter on the right table drops NULL-extended rows—the exact rows you wanted to keep.
These exercises drill the join patterns until the correct syntax is automatic.
This distinction matters for OUTER joins:
- ON conditions are evaluated during the join
- WHERE conditions are evaluated after the join
For INNER JOINs, this doesn't matter—both produce the same result.
For OUTER JOINs, it's the difference between correct and silently wrong:
-- WRONG: Silently converts LEFT JOIN to INNER JOIN
SELECT c.id, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid';
-- Customers with no paid orders are REMOVED (WHERE drops NULL rows)
-- RIGHT: Keeps all customers; filters only matched orders
SELECT c.id, o.id
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
AND o.status = 'paid';
-- Customers with no paid orders show NULL for o.id
Rule: For LEFT/RIGHT/FULL joins, put filters on the optional table in ON, not WHERE.
"Which customers have never ordered?" is a classic pattern:
SELECT c.*
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
WHERE o.customer_id IS NULL;
How it works:
- LEFT JOIN extends all customers with order data (or NULL if no match)
- WHERE filters to only rows where the join found no match
This is often faster than NOT IN (which has NULL pitfalls) and equivalent to NOT EXISTS. See our filtering guide for the NOT EXISTS version.
SQL offers three ways to specify join conditions:
ON: Full flexibility
SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id;
Use ON when column names differ or you need complex conditions.
USING: Same column name shorthand
-- Requires both tables have a column named customer_id
SELECT *
FROM orders
JOIN customers USING (customer_id);
Behavior difference: USING de-duplicates the join column in SELECT *. With ON, you get both orders.customer_id and customers.customer_id; with USING, you get one customer_id.
NATURAL: Implicit join on all matching names
-- Joins on ALL columns with the same name
SELECT *
FROM orders
NATURAL JOIN customers;
Warning: NATURAL JOIN is dangerous. If you later add a column with the same name to both tables (like created_at), the join silently changes. Avoid in production code.
CROSS JOIN produces every combination of rows:
-- 3 sizes × 4 colors = 12 rows
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
Equivalent forms:
-- Implicit (old-style)
SELECT * FROM sizes, colors;
-- Explicit with ON TRUE
SELECT * FROM sizes INNER JOIN colors ON TRUE;
Use cases:
- Generate all combinations for a product catalog
- Create a date spine for time-series analysis
- Fill in missing rows for reporting
Danger: Accidentally omitting a join condition creates a CROSS JOIN. If table A has 1,000 rows and table B has 1,000 rows, you get 1,000,000 rows. You can also use subqueries in the JOIN clause for more complex join conditions.
-- Employees and their managers (both in the same table)
SELECT
e.id,
e.name AS employee,
m.name AS manager
FROM employees e
LEFT JOIN employees m
ON e.manager_id = m.id;
Common patterns:
- Employee → Manager hierarchy
- Comment → Parent comment (threaded discussions)
- Category → Parent category (nested categories)
- Row → Previous row (comparing sequential data)
The key is using table aliases (e, m) to distinguish the two "copies" of the table.
FULL OUTER JOIN keeps all rows from both tables:
-- Reconciliation: find mismatches between systems
SELECT
a.id AS system_a_id,
b.id AS system_b_id,
COALESCE(a.amount, 0) AS amount_a,
COALESCE(b.amount, 0) AS amount_b
FROM system_a a
FULL OUTER JOIN system_b b
ON a.external_id = b.external_id;
Dialect support
| Database | FULL OUTER JOIN | Notes |
|---|---|---|
| PostgreSQL | ✅ Native | Works as expected |
| SQL Server | ✅ Native | Works as expected |
| SQLite | ✅ v3.39+ | Added June 2022 |
| MySQL | ❌ None | Use UNION workaround |
MySQL workaround
SELECT * FROM table_a a
LEFT JOIN table_b b ON a.id = b.a_id
UNION ALL
SELECT * FROM table_a a
RIGHT JOIN table_b b ON a.id = b.a_id
WHERE a.id IS NULL;
Note: Use UNION ALL with a WHERE clause (not plain UNION) for better performance—it avoids re-scanning for duplicates.
RIGHT JOIN is logically equivalent to LEFT JOIN with the tables swapped:
-- These are equivalent:
SELECT * FROM a RIGHT JOIN b ON a.id = b.a_id;
SELECT * FROM b LEFT JOIN a ON a.id = b.a_id;
Best practice: Always use LEFT JOIN and adjust table order. This improves readability (the "main" table is always on the left) and is MySQL's recommendation for portability.
Last order per customer (with LEFT JOIN)
SELECT
c.id,
c.name,
MAX(o.order_date) AS last_order_date,
COUNT(o.id) AS order_count
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;
Note: Use COUNT(o.id) not COUNT(*). When there's no match, LEFT JOIN creates a row with NULLs. COUNT(*) counts that NULL row (returns 1), but COUNT(o.id) ignores it (returns 0).
Avoiding row explosion (pre-aggregate then join)
If you join orders to order_items, each order row multiplies by its item count. If you then SUM(order.amount), you overcount.
Solution: Aggregate in a subquery/CTE before joining. See our aggregation guide for detailed patterns.
-
Rows vanished? Check if a WHERE clause on the outer table is filtering NULL rows. Move the filter to ON.
-
Rows multiplied? You joined on a non-unique key. Check for duplicates in your join columns.
-
Wrong counts/sums? Join multiplication + aggregation. Pre-aggregate or use COUNT(DISTINCT).
-
All rows returned? Your ON condition might always be true (e.g., forgot to include the actual join key).
-
FULL OUTER JOIN failing? Check your database version—MySQL needs UNION workaround, SQLite needs 3.39+.
When to Use SQL Joins
- Combine normalized tables into a single result set (INNER JOIN).
- Keep unmatched rows with LEFT/RIGHT joins (customers with no orders).
- Find rows with no match using LEFT JOIN + WHERE IS NULL (anti-join).
- Relate a table to itself with a self-join (employee → manager).
- Generate all combinations with CROSS JOIN (sizes × colors).
- Keep all rows from both tables with FULL OUTER JOIN (reconciliation).
Check Your Understanding: SQL Joins
List all customers and their last order date, even if they have no orders.
Use LEFT JOIN from customers to orders, aggregate MAX(order_date), GROUP BY customer. The LEFT JOIN ensures customers with no orders appear with NULL for order date. ```sql SELECT c.id, c.name, MAX(o.order_date) AS last_order_date FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id, c.name ORDER BY c.name; ```
What You'll Practice: SQL Joins
Common SQL Joins Pitfalls
- LEFT JOIN + WHERE on right table = silent INNER JOIN (move filter to ON)
- Forgetting the ON clause (Cartesian product)
- Joining without a unique key (row explosion)
- NATURAL JOIN silently changing when columns are added
- FULL OUTER JOIN not supported in MySQL (use UNION workaround)
- RIGHT JOIN readability (prefer LEFT JOIN with swapped table order)
- NULL handling: NULL = NULL is false in joins (use IS NOT DISTINCT FROM if needed)
- Breaking the chain: INNER JOIN after LEFT JOIN removes preserved NULL rows
SQL Joins FAQ
Why did my LEFT JOIN remove rows?
You probably put a filter on the right table in WHERE instead of ON. WHERE o.status = 'paid' runs after the join, dropping all NULL-extended rows (customers with no paid orders). Move the condition to ON: LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'.
INNER vs LEFT JOIN—when do I use each?
INNER returns only matching rows (intersection). LEFT keeps all rows from the left table, filling unmatched columns with NULL. Use LEFT when you need 'all customers, even those with no orders.' Use INNER when missing matches should exclude the row.
How do I find rows with no match (anti-join)?
Use LEFT JOIN ... WHERE right_table.id IS NULL. Example: SELECT c.* FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.customer_id IS NULL; This returns customers who have never ordered. Alternatively, use NOT EXISTS (see our filtering guide).
Why did my join explode rows?
You joined on a non-unique key, creating a many-to-many result. If customer 1 has 3 orders and 4 addresses, joining both produces 12 rows per customer. Fix: join to one table at a time, or aggregate before joining. See our aggregation guide for the 'pre-aggregate then join' pattern.
USING vs ON—when do I use each?
ON works with any condition: ON a.id = b.foreign_id. USING requires identical column names: USING (id). USING also de-duplicates the column in SELECT *. Use ON for flexibility; USING for cleaner syntax when column names match exactly.
What is NATURAL JOIN and should I use it?
NATURAL JOIN automatically joins on all columns with matching names. It's risky: adding a same-named column later silently changes the join condition. Avoid in production—use explicit ON or USING instead.
Why does FULL OUTER JOIN fail in my database?
MySQL doesn't support FULL OUTER JOIN natively—use a UNION of LEFT and RIGHT joins. SQLite added FULL OUTER JOIN support in version 3.39.0 (June 2022); older versions need the UNION workaround. PostgreSQL and SQL Server support it directly.
RIGHT JOIN vs LEFT JOIN—which should I use?
They're logically equivalent—just swap the table order. MySQL docs recommend using LEFT JOIN exclusively for consistency and readability. Most developers never use RIGHT JOIN.
What is CROSS JOIN used for?
CROSS JOIN produces a Cartesian product—every row from A paired with every row from B. Use it to generate combinations: all size/color pairs, all date/product combinations for reporting. Equivalent to INNER JOIN ON TRUE or just listing tables with commas.
How do I join on multiple conditions?
Use AND in the ON clause: ON a.id = b.foreign_id AND a.type = b.type. You can also add constants: ON o.customer_id = c.id AND o.status = 'active'. Multiple conditions are common for composite keys or filtered joins.
SQL Joins Syntax Quick Reference
SELECT c.name, o.order_date
FROM customers c
INNER JOIN orders o
ON o.customer_id = c.id;-- Customers with no orders appear with NULL order columns
SELECT c.name, o.order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id;-- Keep all customers; only join paid orders
-- Customers with no paid orders show NULL for order columns
SELECT c.id, o.id AS paid_order_id
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
AND o.status = 'paid';-- BUG: This silently becomes an INNER JOIN!
-- WHERE drops NULL rows from customers with no paid orders
SELECT c.id, o.id AS paid_order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'; -- Removes customers!-- Find customers who have never ordered
SELECT c.*
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
WHERE o.customer_id IS NULL;-- Get employee name and their manager name
SELECT
e.name AS employee,
m.name AS manager
FROM employees e
LEFT JOIN employees m
ON e.manager_id = m.id;-- Equivalent to ON orders.customer_id = customers.customer_id
-- USING de-duplicates the column in SELECT *
SELECT *
FROM orders
JOIN customers USING (customer_id);-- Generate all size/color combinations
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
-- Equivalent: FROM sizes s, colors c-- Keep all rows from both tables
-- Unmatched rows filled with NULL on the other side
SELECT *
FROM table_a a
FULL OUTER JOIN table_b b
ON a.id = b.a_id;-- MySQL lacks FULL OUTER JOIN; use UNION ALL
SELECT * FROM table_a a
LEFT JOIN table_b b ON a.id = b.a_id
UNION ALL
SELECT * FROM table_a a
RIGHT JOIN table_b b ON a.id = b.a_id
WHERE a.id IS NULL;SELECT
c.id,
c.name,
MAX(o.order_date) AS last_order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.id
GROUP BY c.id, c.name;SELECT
o.id AS order_id,
c.name AS customer,
p.name AS product
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items i ON i.order_id = o.id
JOIN products p ON i.product_id = p.id;SQL Joins Sample Exercises
Fill in the keyword to perform an inner join
INNER JOINIf users has 10 rows and orders has 5 rows (all with valid user_ids), how many rows does INNER JOIN return?
5Join orders with users and products through order_items. Select user email, product name, and quantity.
SELECT users.email, products.name, order_items.quantity
FROM orders
INNER JOIN users ON orders.user_id = users.id
INNER JOIN order_items ON orders.id = order_items.order_id
INNER JOIN products ON order_items.product_id = products.id
+ 22 more exercises