Part 1: Concept Notes: JOINS
Introduction
What is it? Most real-world data is relational, meaning information is split across separate tables that connect to each other through shared fields (like a customer_id appearing in both a customers table and an orders table). A single table rarely tells the whole story. JOINS are SQL's tool for combining rows from two or more tables based on a matching condition, so you can analyze data that spans multiple sources at once.
Why do we need it? If customer details live in one table and their orders live in another, answering a simple question like "which city does our highest-spending customer live in" requires looking at both tables together. Without joins, you'd have to pull data from each table separately and manually stitch it together outside the database, which doesn't scale and is error-prone.
Real-world analogy Think of two receptionists at a company, one holds a binder of employee names and IDs, the other holds a binder of department budgets keyed by department ID. If you want a report showing "employee name next to their department's budget," you need someone to walk between the two receptionists, matching each employee's department ID to the right budget entry. A JOIN is exactly that matching process, automated and applied to every row at once.
Business use case
A common example is an e-commerce report that needs to show "customer name, city, and their orders" together, even though customer information and order information are stored in entirely separate tables, connected only by a shared customer_id.
This chapter covers how join conditions work using ON, how table aliases make multi-table queries far easier to read and write, and walks through every major join type: RIGHT, LEFT, INNER, FULL OUTER, CROSS, IMPLICIT, SELF, and NATURAL.
Dataset Used
Most examples in this document use two connected tables, customers and orders, connected through customer_id.
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(120),
city VARCHAR(60),
signup_at_utc DATETIME
);
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT,
order_code VARCHAR(10) NOT NULL,
status VARCHAR(20) NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
placed_at_utc DATETIME NOT NULL,
INDEX idx_orders_customer_id (customer_id)
);customers
| customer_id | name | city | signup_at_utc | |
|---|---|---|---|---|
| 101 | Aisha | aisha@demo.com | Delhi | 2026-01-01 10:00:00 |
| 102 | Rohan | rohan@demo.com | Mumbai | 2026-01-02 11:00:00 |
| 103 | Meera | meera@demo.com | NULL | 2026-01-03 12:00:00 |
| 104 | Arjun | arjun@demo.com | Delhi | 2026-01-04 13:00:00 |
| 106 | Neha | neha@demo.com | Pune | 2026-01-05 09:30:00 |
| 107 | Ishaan | ishaan@demo.com | Bengaluru | 2026-01-06 18:10:00 |
| 108 | Riya | NULL | Delhi | 2026-01-07 07:45:00 |
| 109 | Aisha | aisha2@demo.com | Jaipur | 2026-01-08 20:00:00 |
orders
| order_id | customer_id | order_code | status | total_amount | placed_at_utc |
|---|---|---|---|---|---|
| 1 | 101 | A | PAID | 499.00 | 2026-01-10 10:00:00 |
| 2 | 102 | B | PAID | 299.00 | 2026-01-10 11:00:00 |
| 3 | 105 | C | PAID | 199.00 | 2026-01-10 12:00:00 |
| 4 | 101 | D | CANCELLED | 999.00 | 2026-01-11 09:00:00 |
| 5 | 106 | E | PAID | 799.00 | 2026-01-11 10:30:00 |
| 6 | 106 | F | PENDING | 149.00 | 2026-01-12 08:20:00 |
| 7 | NULL | G | PAID | 249.00 | 2026-01-12 09:10:00 |
| 8 | 999 | H | PAID | 129.00 | 2026-01-12 15:40:00 |
Notice this dataset is intentionally messy, on purpose: customer 105 and 999 referenced in orders don't actually exist in customers, order 7 has a NULL customer_id, and customer 109 shares the exact same name as customer 101 ("Aisha") despite being a different person. This messiness is exactly what makes join behavior visible and worth studying carefully.
Syntax
| Join Type | Keeps | ON required? |
|---|---|---|
| INNER JOIN | Only rows that match in both tables | Yes |
| LEFT JOIN | All rows from the left table, matched or not | Yes |
| RIGHT JOIN | All rows from the right table, matched or not | Yes |
| FULL OUTER JOIN | All rows from both tables, matched or not | Yes (built differently in MySQL, see below) |
| CROSS JOIN | Every possible combination of both tables | No condition for a Cartesian product |
| Implicit Join | Old-style comma syntax, matching done in WHERE | No (uses WHERE instead) |
| SELF JOIN | A table joined with itself, using two aliases | Yes |
| NATURAL JOIN | Automatically matches on same-named columns | No (automatic) |
SELECT columns
FROM table_a a
[INNER | LEFT | RIGHT] JOIN table_b b
ON a.key_column = b.key_column;Mental Model
Picture two transparent sheets of paper, one listing customers, one listing orders, each row on a sheet has a hole punched through its customer_id value.
- INNER JOIN: hold both sheets up to the light, and only keep the rows where the holes on both sheets line up perfectly.
- LEFT JOIN: keep every row from the customers sheet no matter what, and wherever a hole doesn't line up with anything on the orders sheet, just leave that part blank (NULL).
- RIGHT JOIN: the mirror image, keep every row from the orders sheet no matter what, filling in blanks on the customer side when nothing lines up.
- FULL OUTER JOIN: keep every row from both sheets, filling in blanks wherever either side doesn't have a match.
- CROSS JOIN: forget about lining up holes entirely, just pair every single row from sheet one with every single row from sheet two, regardless of whether anything matches.
How SQL Executes It
For a JOIN with an ON condition, SQL generally works like this conceptually (the actual optimizer may use smarter strategies like index lookups, but this is the logical model):
- Take a row from the first table.
- Compare it against every row in the second table, checking the ON condition.
- For every row in the second table where the ON condition is TRUE, produce one combined output row (pairing the first table's row with that matching second-table row).
- For LEFT/RIGHT/FULL joins, if no match was found for a given row, produce one output row anyway, filling the other table's columns with NULL.
- Repeat for every row in the first table.
WHERE, when present, is applied after this matching process completes, filtering the already-joined result set. This distinction (ON vs. WHERE) turns out to be one of the most important and easy-to-get-wrong details in this entire topic, covered in depth below.
RIGHT JOIN
What it does: A RIGHT JOIN returns all rows from the right table, and only the matching rows from the left table. If no match exists, the left table's columns become NULL.
Scenario 1: Show every order we received, and attach the customer name if we know it
SELECT
o.order_id,
o.order_code,
o.status,
o.total_amount,
o.customer_id,
c.name AS customer_name
FROM customers c
RIGHT JOIN orders o
ON c.customer_id = o.customer_id
ORDER BY o.order_id;Output
| order_id | order_code | status | total_amount | customer_id | customer_name |
|---|---|---|---|---|---|
| 1 | A | PAID | 499.00 | 101 | Aisha |
| 2 | B | PAID | 299.00 | 102 | Rohan |
| 3 | C | PAID | 199.00 | 105 | NULL |
| 4 | D | CANCELLED | 999.00 | 101 | Aisha |
| 5 | E | PAID | 799.00 | 106 | Neha |
| 6 | F | PENDING | 149.00 | 106 | Neha |
| 7 | G | PAID | 249.00 | NULL | NULL |
| 8 | H | PAID | 129.00 | 999 | NULL |
Why this happens: Orders 3 and 8 reference customer_ids (105 and 999) that don't exist anywhere in customers, and order 7 has a NULL customer_id entirely. Since orders is the right table here, every single order still appears in the output regardless, only the customer_name column goes NULL for these unmatched rows.
Scenario 2: Show PAID orders with customer city
SELECT
o.order_id,
o.order_code,
o.status,
o.total_amount,
c.name AS customer_name,
c.city
FROM customers c
RIGHT JOIN orders o
ON c.customer_id = o.customer_id
WHERE o.status = 'PAID'
ORDER BY o.order_id;Output
| order_id | order_code | status | total_amount | customer_name | city |
|---|---|---|---|---|---|
| 1 | A | PAID | 499.00 | Aisha | Delhi |
| 2 | B | PAID | 299.00 | Rohan | Mumbai |
| 3 | C | PAID | 199.00 | NULL | NULL |
| 5 | E | PAID | 799.00 | Neha | Pune |
| 7 | G | PAID | 249.00 | NULL | NULL |
| 8 | H | PAID | 129.00 | NULL | NULL |
Why this happens: The WHERE clause filters on o.status = 'PAID' (a column from the right table, orders), so unmatched orders (3, 7, 8) are still preserved in the output as long as they're PAID, the filter doesn't touch whether a customer match exists, only whether the order's own status qualifies.
Scenario 3: Show city only if it is Delhi, else NULL
SELECT
o.order_code,
c.customer_id,
c.city
FROM customers c
RIGHT JOIN orders o
ON c.customer_id = o.customer_id
AND c.city = 'Delhi';Output
| order_code | customer_id | city |
|---|---|---|
| A | 101 | Delhi |
| B | NULL | NULL |
| C | NULL | NULL |
| D | 101 | Delhi |
| E | NULL | NULL |
| F | NULL | NULL |
| G | NULL | NULL |
| H | NULL | NULL |
Why this happens: Here, c.city = 'Delhi' is placed inside the ON clause, not in a WHERE clause. This means MySQL only considers a customer row a "match" if the customer_id lines up and that customer's city is Delhi. For any order whose customer isn't from Delhi (or doesn't exist, or is NULL), the entire customer side of the join fails to match, so all customer columns (including customer_id and city) become NULL, even though the order itself still appears (since orders is the right table).
ON vs. WHERE
| Aspect | ON (Join Condition) | WHERE (Filter Condition) |
|---|---|---|
| When it's applied | During the matching of rows while joining | After the join result has already been produced |
| Main purpose | Defines how rows from two tables should match | Defines which already-joined rows should remain in the final output |
| Best for | Keeping all rows from the left/right table while still controlling exactly what counts as a match | Filtering the final combined output based on conditions |
This distinction is the single most important idea in this entire chapter. Scenario 2 and Scenario 3 above use the exact same RIGHT JOIN, but placing the city condition in WHERE versus ON produces completely different results, WHERE only removes unmatched rows if their own data fails a check, while ON can cause a row to appear "unmatched" (with NULLs) even though a matching customer_id technically exists.
LEFT JOIN
What it does: A LEFT JOIN returns all rows from the left table, and only the matching rows from the right table. If no match exists, the right table's columns become NULL.
Scenario 1: Show every customer, and the orders they placed
SELECT
c.customer_id,
c.name,
o.order_id,
o.order_code
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id;Output
| customer_id | name | order_id | order_code |
|---|---|---|---|
| 101 | Aisha | 1 | A |
| 101 | Aisha | 4 | D |
| 102 | Rohan | 2 | B |
| 103 | Meera | NULL | NULL |
| 104 | Arjun | NULL | NULL |
| 106 | Neha | 5 | E |
| 106 | Neha | 6 | F |
| 107 | Ishaan | NULL | NULL |
| 108 | Riya | NULL | NULL |
| 109 | Aisha | NULL | NULL |
Why this happens: LEFT JOIN keeps all customers, no matter what. If a customer has one or more orders, you'll see one row per order (notice customer 101 appears twice, once per order). If a customer has no orders at all, order_id and order_code simply become NULL.
Scenario 2: Find customers who have never placed any order
SELECT
c.customer_id,
c.name,
o.order_id,
o.order_code
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;Output
| customer_id | name | order_id | order_code |
|---|---|---|---|
| 103 | Meera | NULL | NULL |
| 104 | Arjun | NULL | NULL |
| 107 | Ishaan | NULL | NULL |
| 108 | Riya | NULL | NULL |
| 109 | Aisha | NULL | NULL |
Why this happens: After the LEFT JOIN, any customer with no matching order ends up with o.order_id = NULL. Filtering with WHERE o.order_id IS NULL keeps only these rows, this is the standard, well-known technique for finding "records in table A with no matching record in table B."
INNER JOIN
What it does: INNER JOIN returns only the rows where a matching value exists in both tables.
Scenario 1: Count of orders per customer
SELECT
c.customer_id,
c.name,
COUNT(*) AS orders_count
FROM customers c
INNER JOIN orders o
ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name;Output
| customer_id | name | orders_count |
|---|---|---|
| 101 | Aisha | 2 |
| 102 | Rohan | 1 |
| 106 | Neha | 2 |
Why this happens: The join keeps only customers who actually have at least 1 matching order. COUNT(*) counts how many matched order rows exist per customer, and GROUP BY groups the result per customer. Customers with zero orders (Meera, Arjun, Ishaan, Riya, and the second Aisha) never appear here at all, since INNER JOIN drops any row without a match on both sides.
Scenario 2: Revenue per customer
SELECT
c.customer_id,
c.name,
SUM(o.total_amount) AS revenue
FROM customers c
INNER JOIN orders o
ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name;Output
| customer_id | name | revenue |
|---|---|---|
| 101 | Aisha | 1498.00 |
| 102 | Rohan | 299.00 |
| 106 | Neha | 948.00 |
Why this happens: The join filters down to only valid customer-order matches, then SUM(total_amount) adds up order amounts per customer. Unmatched orders (customer_id 105, 999, and NULL) are automatically excluded entirely, since there's no matching customer row for them to join against.
FULL OUTER JOIN
What it does: Retrieves all rows from both tables. Where a match exists, it returns the matched combination; where no match exists on either side, the missing side's columns become NULL.
Important MySQL note: MySQL does not support FULL OUTER JOIN directly as a keyword. It has to be built manually by combining a LEFT JOIN and a RIGHT JOIN (or, as shown below, a LEFT JOIN and a second LEFT JOIN with the tables swapped) using a full left side plus only unmatched right-side rows with UNION ALL.
Assumed data
students
| student_id | name |
|---|---|
| 1 | ASHA |
| 2 | RAVI |
| 3 | NEHA |
| NULL | RAJ |
payments
| student_id | amount |
|---|---|
| 1 | 500 |
| 2 | 700 |
| 4 | 800 |
SELECT
s.student_id,
s.name,
p.amount
FROM students s
LEFT JOIN payments p
ON p.student_id = s.student_id
UNION ALL
SELECT
p.student_id,
s.name,
p.amount
FROM students s
RIGHT JOIN payments p
ON p.student_id = s.student_id
WHERE s.student_id IS NULL;Output
| student_id | name | amount |
|---|---|---|
| 1 | ASHA | 500 |
| 2 | RAVI | 700 |
| 3 | NEHA | NULL |
| NULL | RAJ | NULL |
| 4 | NULL | 800 |
Why this works: The first SELECT (a LEFT JOIN from students) captures every student, whether or not they have a payment. The second SELECT (a RIGHT JOIN from students to payments, filtered to WHERE s.student_id IS NULL) specifically captures payment records that have no matching student at all (like student_id 4's payment, which doesn't correspond to any actual student row). UNION ALL appends the unmatched right-side rows without deduplicating the full left-side result. Because the second query is restricted to unmatched payments, the two branches do not overlap, and duplicate matches from the original tables keep their correct multiplicity. This combination effectively reconstructs what a true FULL OUTER JOIN would have produced directly.
CROSS JOIN
What it does: A CROSS JOIN produces the Cartesian product of two tables, every row from one table pairs with every row from the other table, generating m × n total rows. It's rare in everyday CRUD queries, but useful when you specifically want every possible combination (like all customer-coupon pairs, all date × product combinations, or generating a test grid).
SELECT COUNT(*)
FROM customers c
CROSS JOIN orders o;Since customers has 8 rows, and orders has 8 rows:
| COUNT(*) |
|---|
| 64 |
Why this happens: this Cartesian-product CROSS JOIN has no ON condition, so it combines every row with every other row (8 × 8 = 64). This is exactly why CROSS JOIN can grow extremely fast as data increases, doubling either table's row count roughly doubles the entire result set's size.
IMPLICIT JOIN
What it is: An implicit join is the old-style way of writing joins, using a comma-separated FROM clause, with the actual matching condition written inside the WHERE clause instead of an explicit ON.
SELECT
c.customer_id,
c.name,
o.order_code,
o.status
FROM customers c, orders o
WHERE c.customer_id = o.customer_id;Output
| customer_id | name | order_code | status |
|---|---|---|---|
| 101 | Aisha | A | PAID |
| 102 | Rohan | B | PAID |
| 101 | Aisha | D | CANCELLED |
| 106 | Neha | E | PAID |
| 106 | Neha | F | PENDING |
Why this happens: FROM customers c, orders o first conceptually forms a full Cartesian product internally (exactly like a CROSS JOIN would), and then WHERE c.customer_id = o.customer_id filters that huge intermediate set down to only the matching customer-order pairs. Unmatched orders (customer_id 105, 999, or NULL) never survive this filter, so this behaves functionally identically to an INNER JOIN, just written in older syntax.
A critical warning: if you forget the WHERE clause entirely here, this silently becomes a full CROSS JOIN, every customer paired with every order, with no error or warning given.
SELF JOIN
What it is: A SELF JOIN is when a table is joined with itself. It's commonly used to represent hierarchical relationships, like an employee-to-manager structure, where both the employee and their manager are stored in the exact same table. Two table aliases are used to treat the single table as if it were two separate ones.
Assumed data: employees
| emp_id | emp_name | manager_id |
|---|---|---|
| 1 | Kriti | NULL |
| 2 | Siddhant | 1 |
| 3 | Anamika | 1 |
| 4 | Utkarsh | 2 |
| 5 | Ronit | 2 |
| 6 | Shivi | 3 |
Scenario 1: Employee + Manager Name
SELECT
e.emp_name AS employee,
m.emp_name AS manager
FROM employees e
JOIN employees m
ON e.manager_id = m.emp_id
ORDER BY e.emp_id;Output
| employee | manager |
|---|---|
| Siddhant | Kriti |
| Anamika | Kriti |
| Utkarsh | Siddhant |
| Ronit | Siddhant |
| Shivi | Anamika |
Why this happens: Kriti is deliberately excluded from this output entirely, since her own manager_id is NULL, meaning there's no matching manager row for her to join against, and this is a plain JOIN (an INNER JOIN by default), which drops unmatched rows.
Scenario 2: Count Direct Reports per Manager
SELECT
m.emp_name AS manager,
COUNT(*) AS direct_reports
FROM employees e
JOIN employees m
ON e.manager_id = m.emp_id
GROUP BY m.emp_name
ORDER BY m.emp_name;Output
| manager | direct_reports |
|---|---|
| Anamika | 1 |
| Kriti | 2 |
| Siddhant | 2 |
Why this happens: Kriti manages Siddhant and Anamika (2 reports). Siddhant manages Utkarsh and Ronit (2 reports). Anamika manages Shivi (1 report). This same self-join pattern (matching e.manager_id = m.emp_id) is reused here, just with COUNT and GROUP BY added on top.
NATURAL JOIN
What it does: A NATURAL JOIN automatically joins two tables by matching every column that shares the same name in both tables, without needing to write an explicit ON clause at all.
Assumed data: customer_loyalty
| customer_id | tier |
|---|---|
| 101 | GOLD |
| 102 | SILVER |
| 104 | BRONZE |
| 106 | GOLD |
| 201 | PINNACLE |
SELECT
c.customer_id,
cl.tier
FROM customers AS c
NATURAL JOIN customer_loyalty AS cl
ORDER BY c.customer_id;Output
| customer_id | tier |
|---|---|
| 101 | GOLD |
| 102 | SILVER |
| 104 | BRONZE |
| 106 | GOLD |
Why this happens: Both customers and customer_loyalty share a column named customer_id, so NATURAL JOIN automatically uses it as the matching condition, with no ON clause written at all.
Why NATURAL JOIN is risky in real projects: if, at some point later, both tables happen to gain another column with the exact same name (say, both tables later add a name column), the NATURAL JOIN will silently start matching on both columns at once, without any warning, error, or code change on your part. This can quietly change your query's results the moment someone else alters either table's schema, which is exactly why explicit ON conditions are generally considered safer for production code, NATURAL JOIN's automatic matching on every same-named column is convenient, but it trades away control and predictability. The risk is that automatic match set, not ambiguity in explicitly qualified unique columns.
Deep Dive
Internal behavior
Conceptually, every join type is built from the same underlying matching process, checking a condition between rows. A Cartesian-product CROSS JOIN omits that condition; MySQL also permits CROSS JOIN ... ON, which behaves like an inner join rather than a full Cartesian product between rows of two tables. What differs between INNER, LEFT, RIGHT, and FULL is simply what happens to unmatched rows: INNER discards them from both sides, LEFT preserves unmatched rows from the left table only, RIGHT preserves unmatched rows from the right table only, and FULL preserves unmatched rows from both sides.
NULL handling
NULL plays a huge role in join results, in two very different ways. First, if a join key itself is NULL (like order 7's customer_id), it will never match anything, since NULL = anything is UNKNOWN, not TRUE (as covered in the CASE Expression notes), so a NULL join key always behaves as if it has no match. Second, after a LEFT/RIGHT/FULL join, unmatched rows show NULL in the other table's columns, and this NULL is exactly what makes the WHERE column IS NULL technique work for finding "unmatched" rows, as shown in the LEFT JOIN Scenario 2 example.
Performance considerations
Joins on indexed columns (like a primary key or a properly indexed foreign key, such as idx_orders_customer_id in the sample schema) are typically very fast, since the database can quickly look up matching rows instead of scanning entire tables. CROSS JOINs and accidental Cartesian products (from a forgotten WHERE clause in an implicit join) can produce enormous result sets very quickly, since the row count multiplies rather than adds.
Database behavior
MySQL does not support FULL OUTER JOIN directly, so it needs the UNION ALL pattern shown above. MySQL supports INTERSECT from 8.0.31. PostgreSQL and SQL Server, by contrast, do support FULL OUTER JOIN directly. RIGHT JOIN is technically equivalent to a LEFT JOIN with the table order swapped, many style guides actually discourage using RIGHT JOIN at all, preferring to always write LEFT JOIN with the tables reordered instead, purely for consistency and readability across a codebase.
Real-world Use Cases
- Complete department listings: RIGHT JOIN or LEFT JOIN ensuring every department shows up in a report, even ones with zero employees currently assigned
- Customer relationship management: LEFT JOIN to list every registered customer alongside their orders, including customers who haven't purchased anything yet
- Payroll validation: INNER JOIN to find only employees who have both personal records and salary records, excluding anyone with incomplete data
- Recommendation engines: CROSS JOIN to precompute every possible user-category combination before applying ranking or filtering logic
- Data reconciliation and sync auditing: FULL OUTER JOIN (built with UNION in MySQL) to find every mismatch between two systems, records in only one side or the other
- Organizational hierarchies: SELF JOIN to connect employees to their managers, or to build reporting-chain structures
- Quick prototyping or legacy codebases: Implicit joins and NATURAL JOIN, though both come with real risks (a forgotten WHERE clause, or a schema change breaking a NATURAL JOIN silently) that make them less suitable for new production code
Review Tips
Interviewers commonly test this topic alongside these ideas:
- "What's the difference between WHERE and ON in a LEFT JOIN?" — Tests the single most important nuance in this whole topic: ON decides what counts as a match (potentially still keeping a row with NULLs), WHERE filters after the join has already happened (potentially removing a row entirely).
- "How would you find rows in table A that have no match in table B?" — Tests the LEFT JOIN +
WHERE right_table.column IS NULLpattern. - "How do you write a FULL OUTER JOIN in MySQL, since it's not directly supported?" — Tests the LEFT JOIN + RIGHT JOIN + UNION workaround.
- "What's the risk of using NATURAL JOIN in a real production codebase?" — Tests awareness that NATURAL JOIN's automatic column-matching can silently change behavior if either table's schema changes later.
- "What happens if you forget the WHERE clause in an implicit join?" — Tests understanding that implicit joins default to a full Cartesian product (equivalent to CROSS JOIN) without an explicit filtering condition.
- "How would you use a self join to represent a manager-employee hierarchy?" — Tests the two-alias self-join pattern.
A strong answer to any join question should be able to clearly state, in one sentence, exactly which rows get kept and which get dropped (or padded with NULL) for that specific join type, this is the core skill interviewers are checking for.
Common Mistakes to Avoid
Mistake 1: Confusing what ON vs. WHERE actually filters in an outer join
Why it happens: In an INNER JOIN, placing a condition in ON versus WHERE produces the exact same result, so people often don't realize this changes dramatically once an outer join (LEFT, RIGHT, FULL) is involved.
Wrong (intending to keep all orders, but accidentally filtering some out):
SELECT o.order_id, c.name
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id
WHERE c.city = 'Delhi';Since orders is the right table here, this RIGHT JOIN should preserve every order. But placing c.city = 'Delhi' in WHERE means any order whose customer isn't from Delhi (or has no matching customer at all) gets entirely removed from the output, not just NULLed out, silently breaking the "show every order" intent.
Correct: If the goal is to still show every order, but only show Delhi customer details when applicable, put the condition inside the ON clause instead:
SELECT o.order_id, c.name
FROM customers c
RIGHT JOIN orders o ON c.customer_id = o.customer_id AND c.city = 'Delhi';Mistake 2: Forgetting the WHERE clause in an implicit join
Why it happens: The comma-based FROM syntax doesn't visually signal "this needs a matching condition" the way JOIN ... ON does.
Wrong:
SELECT c.name, o.order_code
FROM customers c, orders o;This silently produces a full Cartesian product (8 × 8 = 64 rows in the sample data), with no error or warning.
Correct: Always include the matching condition in WHERE when using implicit join syntax:
SELECT c.name, o.order_code
FROM customers c, orders o
WHERE c.customer_id = o.customer_id;Mistake 3: Using NATURAL JOIN without checking every shared column name
Why it happens: NATURAL JOIN feels convenient, since it requires no explicit condition, but that convenience hides exactly what it's matching on.
Why it matters: If two tables happen to share more than one same-named column (intentionally or by accident), NATURAL JOIN matches on all of them simultaneously, which can silently produce fewer matching rows than expected, or behave differently after a schema change, with zero warning.
Correct: In most real, evolving production codebases, prefer an explicit JOIN ... ON with the exact column(s) you intend to match on, reserving NATURAL JOIN for quick, one-off exploratory queries where you've already verified the table structures.
Mistake 4: Trying to use FULL OUTER JOIN directly in MySQL
Why it happens: FULL OUTER JOIN is a standard SQL keyword in many other databases, so it's a very natural thing to reach for without checking MySQL-specific support first.
Wrong (this raises a syntax error in MySQL):
SELECT *
FROM students s
FULL OUTER JOIN payments p ON s.student_id = p.student_id;Correct: Build the equivalent behavior manually using a LEFT JOIN and a RIGHT JOIN (or a second LEFT JOIN with tables swapped), combined with UNION, as shown in the FULL OUTER JOIN section above.
Mistake 5: Not realizing a CROSS JOIN (or an accidental one) can produce an enormous result set
Why it happens: Multiplying two small numbers together (like 8 × 8 = 64) doesn't seem dangerous, but this scales very differently on real production tables.
Why it matters: If two tables have 10,000 rows each, a CROSS JOIN (or an implicit join missing its WHERE clause) between them would produce 100,000,000 rows, which can seriously strain a database or an application trying to process that result.
Correct: Only use CROSS JOIN deliberately, when you specifically need every possible combination, and always double check implicit join queries have their WHERE clause in place before running them against real tables.
Mistake 6: Forgetting that a self join naturally excludes rows with a NULL foreign key
Why it happens: It's easy to expect every row from the "employee" side of a self join to appear, without considering that a plain JOIN (which defaults to an INNER JOIN) will drop any row whose join key is NULL.
Why it matters: In the manager hierarchy example, Kriti (the top-level manager with manager_id = NULL) never appears as an "employee" in the self-join output, since there's no manager row for NULL to match against.
Correct: If the very top of a hierarchy needs to appear in the output too (even without a manager), a LEFT JOIN should be used instead of a plain (inner) JOIN, so unmatched top-level rows are preserved with NULL manager details, rather than being silently dropped.
Key Takeaways
- INNER JOIN keeps only rows that match in both tables; LEFT JOIN keeps all left-table rows; RIGHT JOIN keeps all right-table rows; FULL OUTER JOIN keeps all rows from both, all three outer join types pad unmatched sides with NULL
- The distinction between ON and WHERE is the single most important nuance in this chapter: ON decides what counts as a match (rows can still appear, padded with NULL, if the ON condition fails), while WHERE filters the already-joined output (which can remove an otherwise-preserved outer-join row entirely)
- The classic "find unmatched rows" pattern is a LEFT (or RIGHT) JOIN followed by
WHERE other_table.column IS NULL - CROSS JOIN produces every possible combination between two tables (m × n rows), with no ON condition, and can grow extremely large very quickly
- Implicit joins (comma-separated FROM, condition in WHERE) behave like INNER JOIN when written correctly, but silently become a full Cartesian product if the WHERE clause is ever forgotten
- SELF JOIN uses two aliases on the same table to represent relationships like employee-to-manager hierarchies, and being a plain (inner) join by default, it naturally excludes rows whose join key is NULL
- NATURAL JOIN automatically matches on every same-named column between two tables, convenient, but risky, since a future schema change (a newly added same-named column) can silently alter its behavior without warning
- MySQL has no direct FULL OUTER JOIN keyword; it must be built manually using a LEFT JOIN and a RIGHT JOIN (or a second LEFT JOIN with tables swapped) combined with UNION
- A NULL value in a join key column never matches anything, since
NULL = anythingevaluates to UNKNOWN, not TRUE, this is why unmatched or orphaned rows with NULL keys behave consistently across every join type