SQL Joins Explained: INNER, LEFT, RIGHT, and FULL With Visuals

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 ANALYZE to 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.

Hot this week

Subqueries vs CTEs: Writing Cleaner SQL

Compare SQL CTEs and subqueries with code, performance notes, and a decision table. Learn when each wins, then refactor your queries today.

Cohort Analysis: Understand Retention Over Time

Learn cohort analysis in Python with Pandas. Build retention tables, heatmaps, and churn insights step by step. Copy the code and start analyzing today.

SQL vs Pandas vs Polars: Which Should You Use for Which Task?

Compare SQL, Pandas, and Polars on speed, memory, and scale. Use our task-by-task decision guide and code examples to pick the right tool today.

ETL vs ELT: Understanding Modern Data Pipelines

Compare ETL vs ELT pipelines with code, a decision table, and real use cases. Pick the right architecture for your warehouse—read the full guide now.

HAVING vs WHERE: Filtering Aggregated Data Without Confusion

Learn when to use HAVING vs WHERE in SQL with runnable examples, a comparison table, and pandas equivalents. Master aggregate filtering today.

Topics

Subqueries vs CTEs: Writing Cleaner SQL

Compare SQL CTEs and subqueries with code, performance notes, and a decision table. Learn when each wins, then refactor your queries today.

Cohort Analysis: Understand Retention Over Time

Learn cohort analysis in Python with Pandas. Build retention tables, heatmaps, and churn insights step by step. Copy the code and start analyzing today.

SQL vs Pandas vs Polars: Which Should You Use for Which Task?

Compare SQL, Pandas, and Polars on speed, memory, and scale. Use our task-by-task decision guide and code examples to pick the right tool today.

ETL vs ELT: Understanding Modern Data Pipelines

Compare ETL vs ELT pipelines with code, a decision table, and real use cases. Pick the right architecture for your warehouse—read the full guide now.

HAVING vs WHERE: Filtering Aggregated Data Without Confusion

Learn when to use HAVING vs WHERE in SQL with runnable examples, a comparison table, and pandas equivalents. Master aggregate filtering today.

Prompt Patterns for Data Analysts: 20 Prompts That Save Hours

Copy 20 tested prompt patterns for data analysts to clean data, write SQL, debug Pandas, and draft reports faster. Grab the templates now.

Aggregate Functions and GROUP BY: Summarizing Data With SQL

Learn SQL aggregate functions and GROUP BY with runnable examples, HAVING, ROLLUP, and pandas equivalents. Copy the queries and start summarizing data.

KPI Visualization: Choosing and Displaying the Right Metrics

Learn how to pick KPIs and chart them correctly. Includes Python code, chart-selection tables, and use cases. Build better dashboards today.

Related Articles

Popular Categories