Syntax Cache
BlogMethodFeaturesHow It WorksBuild a Game
  1. Home
  2. SQL
  3. SQL Joins Practice
SQL25 exercises

SQL Joins Practice

Drill SQL joins until row explosion and silent data loss stop happening: ON vs WHERE filter placement, anti-joins, USING/NATURAL, CROSS JOIN, and dialect differences for FULL OUTER.

Common ErrorsQuick ReferencePractice
Warm-up1 / 2

Can you write this from memory?

Write a query to join the orders table with the users table on user_id, selecting all columns.

On this page
  1. 1ON vs WHERE
  2. 2Anti-join: find rows with no match
  3. 3USING vs ON vs NATURAL
  4. ON: Full flexibility
  5. USING: Same column name shorthand
  6. NATURAL: Implicit join on all matching names
  7. 4CROSS JOIN: intentional Cartesian products
  8. 5Self-joins: relating a table to itself
  9. 6FULL OUTER JOIN and dialect differences
  10. Dialect support
  11. MySQL workaround
  12. 7RIGHT JOIN: just use LEFT JOIN instead
  13. 8Join + aggregation patterns
  14. Last order per customer (with LEFT JOIN)
  15. Avoiding row explosion (pre-aggregate then join)
  16. 9Debugging checklist: "Why is my join wrong?"
  17. 10References
ON vs WHEREAnti-join: find rows with no matchUSING vs ON vs NATURALCROSS JOIN: intentional Cartesian productsSelf-joins: relating a table to itselfFULL OUTER JOIN and dialect differencesRIGHT JOIN: just use LEFT JOIN insteadJoin + aggregation patternsDebugging checklist: "Why is my join wrong?"References

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.

Related SQL Topics
SQL Filtering: WHERE, LIKE, IN, BETWEEN, NULLSQL Aggregation: GROUP BY, HAVING, COUNT, SUM, AVGSQL Subqueries

For LEFT/RIGHT joins, put filters on the optional table in ON, not WHERE. A WHERE filter on the right table silently converts your LEFT JOIN back to an INNER JOIN. For a quick reference of all join syntax, see the SQL joins cheat sheet.

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.


Ready to practice?

Start practicing SQL Joins with spaced repetition

Use LEFT JOIN ... WHERE right_table.id IS NULL to find rows with no match. This is often faster than NOT IN (which has NULL pitfalls) and equivalent to NOT EXISTS.

"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:

  1. LEFT JOIN extends all customers with order data (or NULL if no match)
  2. 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.


Use ON for flexibility (different column names, complex conditions). Use USING when column names match exactly. Avoid NATURAL JOIN—it silently changes when columns are added.

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

DatabaseFULL OUTER JOINNotes
PostgreSQL✅ NativeWorks as expected
SQL Server✅ NativeWorks as expected
SQLite✅ v3.39+Added June 2022
MySQL❌ NoneUse 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.


  1. Rows vanished? Check if a WHERE clause on the outer table is filtering NULL rows. Move the filter to ON.

  2. Rows multiplied? You joined on a non-unique key. Check for duplicates in your join columns.

  3. Wrong counts/sums? Join multiplication + aggregation. Pre-aggregate or use COUNT(DISTINCT).

  4. All rows returned? Your ON condition might always be true (e.g., forgot to include the actual join key).

  5. FULL OUTER JOIN failing? Check your database version—MySQL needs UNION workaround, SQLite needs 3.39+.


  • PostgreSQL: JOIN Syntax
  • MySQL: JOIN Clause
  • SQLite: JOIN Syntax (v3.39 release notes)
  • SQL Standard: USING vs NATURAL

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

Prompt

List all customers and their last order date, even if they have no orders.

What a strong answer looks like

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

INNER JOIN syntax and semanticsLEFT OUTER JOIN (keeping unmatched rows)RIGHT OUTER JOIN (and why to prefer LEFT)FULL OUTER JOIN (and dialect support)Self-joins (employee → manager)CROSS JOIN (Cartesian products)ON vs WHERE filter placementJOIN ... USING (column name shorthand)Anti-join pattern (find missing matches)Multiple table 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

INNER JOIN
SELECT c.name, o.order_date
FROM customers c
INNER JOIN orders o
  ON o.customer_id = c.id;
LEFT JOIN (keep all customers)
-- 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;
LEFT JOIN + filtered ON (correct)
-- 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';
LEFT JOIN + WHERE (WRONG)
-- 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!
Anti-join (find rows with no match)
-- 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;
Self-join (employee → manager)
-- 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;
JOIN ... USING (same column name)
-- Equivalent to ON orders.customer_id = customers.customer_id
-- USING de-duplicates the column in SELECT *
SELECT *
FROM orders
JOIN customers USING (customer_id);
CROSS JOIN (generate combinations)
-- Generate all size/color combinations
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;

-- Equivalent: FROM sizes s, colors c
FULL OUTER JOIN (PostgreSQL)
-- 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;
FULL OUTER JOIN (MySQL workaround)
-- 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;
JOIN + aggregate (last order per customer)
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;
Multiple joins
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

Example 1Difficulty: 2/5

Fill in the keyword to perform an inner join

INNER JOIN
Example 2Difficulty: 2/5

If users has 10 rows and orders has 5 rows (all with valid user_ids), how many rows does INNER JOIN return?

5
Example 3Difficulty: 3/5

Join 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

Quick Reference
SQL Joins Cheat Sheet →

Copy-ready syntax examples for quick lookup

Start practicing SQL Joins

Free daily exercises with spaced repetition. No credit card required.

← Back to SQL Syntax Practice
Syntax Cache

Build syntax muscle memory with spaced repetition.

Product

  • Pricing
  • Our Method
  • Daily Practice
  • Design Patterns
  • Interview Prep

Resources

  • Blog
  • Compare
  • Cheat Sheets
  • Vibe Coding
  • Muscle Memory

Languages

  • Python
  • JavaScript
  • TypeScript
  • Rust
  • SQL
  • GDScript

Legal

  • Terms
  • Privacy
  • Contact

© 2026 Syntax Cache

Cancel anytime in 2 clicks. Keep access until the end of your billing period.

No refunds for partial billing periods.