SQL joins combine rows from two tables based on a related column, usually a primary key matched to a foreign key. Every join answers one question: what happens to rows that have no match on the other side? INNER JOIN drops them. LEFT JOIN and RIGHT JOIN keep them from one side. FULL JOIN keeps them from both. Once you can predict the output row count of each join, you can debug most join bugs before you run the query.
Quick Takeaways
- INNER JOIN returns only rows with a match in both tables.
- LEFT JOIN keeps every row from the left table and fills missing right-side columns with
NULL. - RIGHT JOIN is the mirror image. Most teams rewrite it as a LEFT JOIN by swapping table order.
- FULL JOIN keeps all rows from both tables. MySQL lacks it, so you emulate it with
UNION.
| Join Type | Keeps Unmatched Left Rows | Keeps Unmatched Right Rows | Typical Use Case |
|---|---|---|---|
| INNER JOIN | No | No | Orders with valid customers |
| LEFT JOIN | Yes | No | Customers with or without orders |
| RIGHT JOIN | No | Yes | Orphaned records in the child table |
| FULL JOIN | Yes | Yes | Reconciling two data sources |
The Sample Data Used in Every Example
All examples use two small tables. Table A is customers (left). Table B is orders (right). The data is built to include unmatched rows on both sides.
customers
| customer_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carla |
| 4 | Dev |
orders
| order_id | customer_id | amount |
|---|---|---|
| 101 | 1 | 250 |
| 102 | 1 | 90 |
| 103 | 2 | 40 |
| 104 | 5 | 300 |
Three facts matter here:
- Alice has two orders, so she matches twice.
- Carla and Dev have no orders. They appear only in the left table.
- Order 104 points to
customer_id = 5, which does not exist. It appears only in the right table.
The One Diagram That Explains All Four Joins
Picture every row as falling into one of three zones: A only, Match, or B only. Each join keeps a different combination of zones.
A only Match B only
(Carla, (Alice x2, (order
Dev) Bob) 104)
┌────────────┬────────────┬────────────┐
INNER JOIN │ │ ██████████ │ │ 3 rows
├────────────┼────────────┼────────────┤
LEFT JOIN │ ██████████ │ ██████████ │ │ 5 rows
├────────────┼────────────┼────────────┤
RIGHT JOIN │ │ ██████████ │ ██████████ │ 4 rows
├────────────┼────────────┼────────────┤
FULL JOIN │ ██████████ │ ██████████ │ ██████████ │ 6 rows
└────────────┴────────────┴────────────┘
A filled block means those rows survive in the output. The row counts match the real results below.
INNER JOIN: Only the Matches
INNER JOIN returns a row only when the join condition is true in both tables. It is the default join in most SQL dialects: writing JOIN alone means INNER JOIN.
┌───────┐
│ A │
│ ┌───┴───┐
│ │███████│
└───┤███████│ B
│███████│
└───────┘
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers AS c
INNER JOIN orders AS o
ON c.customer_id = o.customer_id; -- join key: the matching condition
| customer_id | name | order_id | amount |
|---|---|---|---|
| 1 | Alice | 101 | 250 |
| 1 | Alice | 102 | 90 |
| 2 | Bob | 103 | 40 |
Carla, Dev, and order 104 are gone. Use INNER JOIN when an unmatched row is meaningless for your analysis, such as revenue per customer where you only care about paying customers.
LEFT JOIN: Keep Everything on the Left
LEFT JOIN (also called LEFT OUTER JOIN) returns all rows from the left table. Where no right-side match exists, the right columns are NULL.
┌───────────┐
│███████┌───┴───┐
│███████│███████│
│███████│███████│ B
│███████└───┬───┘
└───────────┘
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id;
| customer_id | name | order_id | amount |
|---|---|---|---|
| 1 | Alice | 101 | 250 |
| 1 | Alice | 102 | 90 |
| 2 | Bob | 103 | 40 |
| 3 | Carla | NULL | NULL |
| 4 | Dev | NULL | NULL |
The Anti-Join Pattern
Add a WHERE ... IS NULL filter on the right table’s key to find left rows with no match. This is called an anti-join.
-- Customers who never placed an order
SELECT c.customer_id, c.name
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL; -- filter on the right table's key, never a nullable column
This returns Carla and Dev.
RIGHT JOIN: Keep Everything on the Right
RIGHT JOIN is the mirror of LEFT JOIN. It keeps all rows from the right table and fills missing left-side columns with NULL.
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers AS c
RIGHT JOIN orders AS o
ON c.customer_id = o.customer_id;
| customer_id | name | order_id | amount |
|---|---|---|---|
| 1 | Alice | 101 | 250 |
| 1 | Alice | 102 | 90 |
| 2 | Bob | 103 | 40 |
| NULL | NULL | 104 | 300 |
The orphaned order 104 survives. This pattern is useful for data quality audits, where unmatched child records signal broken foreign keys. In practice, most style guides discourage RIGHT JOIN. Reading a query top to bottom is easier when the “main” table always comes first, so you swap the table order and use LEFT JOIN instead.
FULL JOIN: Keep Everything
FULL JOIN (or FULL OUTER JOIN) returns the union of LEFT and RIGHT results. Unmatched rows from either side appear with NULL in the other side’s columns.
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers AS c
FULL OUTER JOIN orders AS o
ON c.customer_id = o.customer_id;
| customer_id | name | order_id | amount |
|---|---|---|---|
| 1 | Alice | 101 | 250 |
| 1 | Alice | 102 | 90 |
| 2 | Bob | 103 | 40 |
| 3 | Carla | NULL | NULL |
| 4 | Dev | NULL | NULL |
| NULL | NULL | 104 | 300 |
FULL JOIN in MySQL
MySQL does not support FULL OUTER JOIN. Emulate it by combining a LEFT JOIN and a right-side anti-join:
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers AS c
LEFT JOIN orders AS o ON c.customer_id = o.customer_id
UNION ALL
SELECT c.customer_id, c.name, o.order_id, o.amount
FROM customers AS c
RIGHT JOIN orders AS o ON c.customer_id = o.customer_id
WHERE c.customer_id IS NULL; -- keeps only the B-only rows, avoiding duplicates
Use UNION ALL with the anti-join filter. Plain UNION forces a deduplication step that is slower and can silently collapse legitimate duplicate rows.
Python Implementation: SQL Joins vs. Pandas Merge
Data scientists often switch between SQL and pandas. The DataFrame.merge() method maps directly onto SQL join types through the how parameter. The indicator=True flag adds a _merge column that tells you which zone each row came from.
import pandas as pd
customers = pd.DataFrame({
"customer_id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Carla", "Dev"],
})
orders = pd.DataFrame({
"order_id": [101, 102, 103, 104],
"customer_id": [1, 1, 2, 5],
"amount": [250, 90, 40, 300],
})
# how= maps to SQL: inner, left, right, outer (FULL)
for how in ["inner", "left", "right", "outer"]:
result = customers.merge(
orders,
on="customer_id", # join key present in both frames
how=how,
indicator=True, # adds _merge: left_only / right_only / both
)
print(f"\n--- {how.upper()} ({len(result)} rows) ---")
print(result)
# Anti-join in pandas: customers with no orders
merged = customers.merge(orders, on="customer_id", how="left", indicator=True)
no_orders = merged.loc[merged["_merge"] == "left_only", ["customer_id", "name"]]
print(no_orders)
| SQL Join | pandas how= |
_merge Values Present |
|---|---|---|
| INNER JOIN | "inner" |
both |
| LEFT JOIN | "left" |
both, left_only |
| RIGHT JOIN | "right" |
both, right_only |
| FULL JOIN | "outer" |
both, left_only, right_only |
For PySpark, the same logic applies: df1.join(df2, on="customer_id", how="left"). Valid how values include inner, left, right, outer, left_anti, and left_semi.
Four Join Mistakes That Corrupt Your Results
1. Row Multiplication From Duplicate Keys
Alice has two orders, so a join produces two Alice rows. If you then run SUM() on a customer-level column, such as a credit limit, you double-count it. Aggregate the many-side table first, then join:
WITH order_totals AS (
SELECT customer_id, SUM(amount) AS total_spent
FROM orders
GROUP BY customer_id -- collapse to one row per key before joining
)
SELECT c.name, ot.total_spent
FROM customers AS c
LEFT JOIN order_totals AS ot
ON c.customer_id = ot.customer_id;
2. Filtering in WHERE Instead of ON
A WHERE filter on the right table turns a LEFT JOIN into an INNER JOIN. The NULL rows fail the filter and disappear.
-- Wrong: Carla and Dev vanish
SELECT c.name, o.amount
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.amount > 50;
-- Right: the filter belongs to the join condition
SELECT c.name, o.amount
FROM customers c
LEFT JOIN orders o
ON c.customer_id = o.customer_id
AND o.amount > 50;
3. NULL Keys Never Match
In SQL, NULL = NULL evaluates to unknown, not true. Rows with a NULL join key never match each other, even in a FULL JOIN. Clean or coalesce keys before joining.
4. Mismatched Data Types
Joining an integer column to a string column forces implicit casts. That can silently drop matches ('007' versus 7) and disable index usage. Cast explicitly and check types first.
Join Algorithms and Performance
The logical join type does not determine speed. The database optimizer picks a physical algorithm.
| Algorithm | Best When | Computational Cost | Notes |
|---|---|---|---|
| Nested Loop | One side is small, or the key is indexed | O(n × m) without an index | Fast for selective lookups |
| Hash Join | Large, unsorted inputs with equality conditions | O(n + m), needs memory | Default for big analytical joins |
| Merge Join | Both inputs are already sorted on the key | O(n + m) after sorting | Efficient on indexed or pre-sorted data |
Optimization checklist:
- Index the foreign key column on the many-side table.
- Filter rows before the join with subqueries or CTEs when the optimizer does not push predicates down.
- Select only the columns you need. Wide rows slow hash joins.
- Run
EXPLAIN ANALYZEto see the chosen algorithm and the actual row counts.
Practical Examples and Real-World Use Cases
Identifying Churn Risk With a LEFT JOIN
A subscription business wants customers who have not purchased in the last 90 days, including customers who never purchased at all.
SELECT c.customer_id, c.name, MAX(o.order_date) AS last_order
FROM customers AS c
LEFT JOIN orders AS o
ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name
HAVING MAX(o.order_date) IS NULL
OR MAX(o.order_date) < CURRENT_DATE - INTERVAL '90 days';
An INNER JOIN would miss every customer who never ordered, which understates churn.
Reconciling Billing and Payments With a FULL JOIN
Finance teams compare invoices against bank payments. A FULL JOIN on invoice_id surfaces three problem classes in one pass:
- Invoices with no payment (left only)
- Payments with no invoice (right only)
- Matched pairs with different amounts (both, where
amount_billed <> amount_paid)
Auditing Orphaned Records With a RIGHT JOIN
Before loading data into a warehouse, engineers check for child rows that reference deleted parents. Order 104 in our sample data is exactly this case: a foreign key pointing to a missing customer. The anti-join WHERE c.customer_id IS NULL isolates these rows for review.
Feature Engineering for Machine Learning
Joining a user table to an event table with a LEFT JOIN keeps every user in the training set. Users with no events get NULL aggregates, which you then impute, for example with fillna(0) for count features. An INNER JOIN would drop inactive users and bias the model toward engaged ones.
Choosing the Right Join: Decision Table
| Question | Answer | Use |
|---|---|---|
| Do I want only records that exist in both tables? | Yes | INNER JOIN |
| Must every row of my main table survive? | Yes | LEFT JOIN |
| Am I hunting for rows with no match? | Yes | LEFT JOIN + IS NULL |
| Do I need to compare two sources and see gaps on both sides? | Yes | FULL JOIN |
| Do I need every combination of rows? | Yes | CROSS JOIN |
Frequently Asked Questions
What is the difference between INNER JOIN and LEFT JOIN?
INNER JOIN returns only rows that match in both tables. LEFT JOIN returns all rows from the left table plus matching rows from the right, filling unmatched right-side columns with NULL. In the sample data, INNER JOIN returns 3 rows and LEFT JOIN returns 5.
Does MySQL support FULL OUTER JOIN?
No. MySQL has no native FULL OUTER JOIN. Emulate it by running a LEFT JOIN, then a RIGHT JOIN filtered to unmatched rows (WHERE left_key IS NULL), and combining them with UNION ALL. PostgreSQL, SQL Server, Oracle, and recent SQLite versions support it natively.
Which SQL join is the fastest?
No join type is inherently fastest. Speed depends on indexes, table sizes, and the physical algorithm the optimizer chooses (nested loop, hash, or merge). INNER JOIN often runs faster because it can discard rows early, but a well-indexed LEFT JOIN can outperform a poorly indexed INNER JOIN. Use EXPLAIN ANALYZE to measure.
Why does my LEFT JOIN return fewer rows than expected?
A WHERE condition on a right-table column removes the NULL rows that LEFT JOIN produced, which turns it into an INNER JOIN. Move that condition into the ON clause to preserve unmatched left rows.




