A B
Database

SQL Joins Explained with Practical Examples

Mayur Dabhi
Mayur Dabhi
February 25, 2026
20 min read

A report shows 42 customers when the dashboard says there should be 45. Or an admin page lists every order twice. Nine times out of ten, the bug is a JOIN: the wrong type, a missing condition, or a filter in the wrong place that quietly drops or duplicates rows.

This post walks through each JOIN type (INNER, LEFT, RIGHT, FULL OUTER, CROSS and SELF) using one small dataset, so you can see exactly which rows each one keeps and which it throws away. Every query runs as is in MySQL.

Why JOINs Matter

In a normalized schema, data is split across tables to avoid storing the same thing twice. JOINs are how you put it back together when you query it. Get the JOIN type wrong and the query still runs; it just returns the wrong rows.

Setting Up Our Sample Data

All the examples use two tables, employees and departments. It's the classic example for a reason: it has exactly the kind of gaps that make JOIN types behave differently.

SQL - Create Tables
-- Create departments table
CREATE TABLE departments (
    dept_id INT PRIMARY KEY,
    dept_name VARCHAR(50) NOT NULL,
    location VARCHAR(100)
);

-- Create employees table
CREATE TABLE employees (
    emp_id INT PRIMARY KEY,
    emp_name VARCHAR(100) NOT NULL,
    dept_id INT,
    salary DECIMAL(10, 2),
    hire_date DATE
);

-- Insert sample data
INSERT INTO departments VALUES
    (1, 'Engineering', 'Building A'),
    (2, 'Marketing', 'Building B'),
    (3, 'Sales', 'Building C'),
    (4, 'HR', 'Building D');

INSERT INTO employees VALUES
    (101, 'Alice Johnson', 1, 85000, '2022-03-15'),
    (102, 'Bob Smith', 1, 75000, '2023-01-10'),
    (103, 'Carol Davis', 2, 65000, '2021-06-20'),
    (104, 'David Wilson', 3, 70000, '2020-09-05'),
    (105, 'Eve Martinez', NULL, 60000, '2024-02-01'),
    (106, 'Frank Brown', 1, 90000, '2019-11-30');
employees
emp_idemp_namedept_id
101Alice1
102Bob1
103Carol2
104David3
105EveNULL
106Frank1
departments
dept_iddept_name
1Engineering
2Marketing
3Sales
4HR

Three details in this data matter:

Those edge cases are where the JOIN types differ. With perfectly matched data, most of them would return the same thing.

The JOIN Types Overview

Here's the whole set on one diagram. Each section below goes through one of them with real output.

SQL JOIN Types - Visual Reference INNER JOIN Only matching rows LEFT JOIN All from left + matches RIGHT JOIN All from right + matches FULL OUTER All rows from both CROSS JOIN Cartesian product SELF JOIN Table joined to itself Legend Table A (Left) Table B (Right)

Visual comparison of all SQL JOIN types

INNER JOIN: The One You'll Use Most

Use this when you only care about rows that exist on both sides. If an employee has no matching department, or a department has no employees, that row is dropped from the result. It's also what you get when you write plain JOIN without a keyword in front.

INNER JOIN Visualization employees departments RESULT Eve (no dept) HR (no employees)
SQL - INNER JOIN
SELECT 
    e.emp_id,
    e.emp_name,
    d.dept_name,
    d.location
FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id;
Result (5 rows)
emp_idemp_namedept_namelocation
101Alice JohnsonEngineeringBuilding A
102Bob SmithEngineeringBuilding A
103Carol DavisMarketingBuilding B
104David WilsonSalesBuilding C
106Frank BrownEngineeringBuilding A
What's Missing?
  • Eve Martinez is excluded because she has NULL dept_id
  • HR department is excluded because no employees belong to it

LEFT JOIN (LEFT OUTER JOIN)

Reach for LEFT JOIN when you need every row from the left table, matched or not. Rows from the right table come along when they match; when they don't, their columns are filled with NULL.

LEFT JOIN Visualization ALL employees departments FROM
SQL - LEFT JOIN
SELECT 
    e.emp_id,
    e.emp_name,
    d.dept_name,
    d.location
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id;
Result (6 rows, all employees)
emp_idemp_namedept_namelocation
101Alice JohnsonEngineeringBuilding A
102Bob SmithEngineeringBuilding A
103Carol DavisMarketingBuilding B
104David WilsonSalesBuilding C
105Eve MartinezNULLNULL
106Frank BrownEngineeringBuilding A

Eve Martinez shows up now, with NULL in the department columns because she has no department.

Finding Unmatched Records

Add a WHERE ... IS NULL on the right table and you get the rows that don't have a match:

-- Find employees without a department
SELECT e.emp_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
WHERE d.dept_id IS NULL;
The WHERE Clause That Turns LEFT JOIN Into INNER JOIN

This one catches people all the time. If you filter on a right table column in WHERE, like WHERE d.location = 'Building A', every row where d.location is NULL fails the test, and Eve disappears again. Your LEFT JOIN now behaves exactly like an INNER JOIN. If you want to filter the right table but keep all left rows, move the condition into the ON clause: LEFT JOIN departments d ON e.dept_id = d.dept_id AND d.location = 'Building A'.

RIGHT JOIN (RIGHT OUTER JOIN)

RIGHT JOIN is LEFT JOIN with the sides flipped: you keep every row from the right table, and left table columns are NULL where nothing matches.

SQL - RIGHT JOIN
SELECT 
    e.emp_id,
    e.emp_name,
    d.dept_name,
    d.location
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id;
Result (6 rows, all departments)
emp_idemp_namedept_namelocation
101Alice JohnsonEngineeringBuilding A
102Bob SmithEngineeringBuilding A
106Frank BrownEngineeringBuilding A
103Carol DavisMarketingBuilding B
104David WilsonSalesBuilding C
NULLNULLHRBuilding D

This time the HR department appears (with NULL employee columns), and Eve is gone because she doesn't match any department.

I Rarely Write RIGHT JOIN

Most developers, me included, just use LEFT JOIN and swap the table order. It's easier to read when the table you're keeping all rows from is always the one after FROM. These two queries return the same rows:

-- These produce identical results:
SELECT * FROM A RIGHT JOIN B ON A.id = B.id;
SELECT * FROM B LEFT JOIN A ON B.id = A.id;

FULL OUTER JOIN

FULL OUTER JOIN keeps all rows from both tables. Matching rows are combined; unmatched rows from either side come through with NULL on the other side. PostgreSQL and SQL Server support it directly. MySQL doesn't, so you have to emulate it.

FULL OUTER JOIN Visualization ALL ALL MATCHED
SQL - FULL OUTER JOIN
-- MySQL doesn't support FULL OUTER JOIN directly
-- Use UNION of LEFT and RIGHT JOINs:

SELECT 
    e.emp_id,
    e.emp_name,
    d.dept_name,
    d.location
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id

UNION

SELECT 
    e.emp_id,
    e.emp_name,
    d.dept_name,
    d.location
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id;
Result (7 rows, everything from both tables)
emp_idemp_namedept_namelocation
101Alice JohnsonEngineeringBuilding A
102Bob SmithEngineeringBuilding A
103Carol DavisMarketingBuilding B
104David WilsonSalesBuilding C
105Eve MartinezNULLNULL
106Frank BrownEngineeringBuilding A
NULLNULLHRBuilding D

One thing to watch with the UNION trick: UNION removes duplicate rows. Here that's what we want, since the matched rows appear in both halves. But if your selected columns can legitimately produce identical rows (say you drop emp_id and two employees share a name), they'll be collapsed into one. In that case use UNION ALL and add WHERE e.emp_id IS NULL to the second query so it only contributes the unmatched right rows.

CROSS JOIN: The Cartesian Product

CROSS JOIN pairs every row of one table with every row of the other, with no condition at all. That's the Cartesian product. Six employees and four departments give you 6 × 4 = 24 rows.

CROSS JOIN: Cartesian Product employees Alice Bob Carol David Eve Frank departments Engineering Marketing Sales HR 6 × 4 = 24 rows
SQL - CROSS JOIN
-- Explicit CROSS JOIN syntax
SELECT 
    e.emp_name,
    d.dept_name
FROM employees e
CROSS JOIN departments d;

-- Alternative implicit syntax (comma-separated)
SELECT 
    e.emp_name,
    d.dept_name
FROM employees e, departments d;
Watch the Row Count

The result grows fast. Two 1,000-row tables give you 1,000,000 rows. It's still useful on purpose, for things like:

  • Generating test data
  • Creating date/product combinations for reports
  • Producing all possible pairs for analysis

SELF JOIN: Joining a Table to Itself

Org charts, category trees, "which rows in this table relate to other rows in the same table": that's where a self join comes in. There's no SELF JOIN keyword. You join the table to itself under two different aliases, so SQL can tell the two copies apart.

To try it, add a manager_id column to employees:

SQL - Self Join Example
-- Add manager relationship
ALTER TABLE employees ADD COLUMN manager_id INT;

UPDATE employees SET manager_id = 106 WHERE emp_id IN (101, 102);
UPDATE employees SET manager_id = 103 WHERE emp_id = 104;

-- Self join to find employees and their managers
SELECT 
    e.emp_name AS employee,
    m.emp_name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.emp_id;
Result: Employee and Manager Pairs
employeemanager
Alice JohnsonFrank Brown
Bob SmithFrank Brown
Carol DavisNULL
David WilsonCarol Davis
Eve MartinezNULL
Frank BrownNULL

Multiple JOINs: More Than Two Tables

Real queries often touch three or more tables. You chain JOIN clauses, and each one joins against the result built so far:

SQL - Multiple JOINs
-- Add a projects table
CREATE TABLE projects (
    project_id INT PRIMARY KEY,
    project_name VARCHAR(100),
    dept_id INT
);

INSERT INTO projects VALUES
    (1, 'Website Redesign', 2),
    (2, 'Mobile App', 1),
    (3, 'Data Analytics', 1);

-- Join three tables
SELECT 
    e.emp_name,
    d.dept_name,
    p.project_name
FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id
INNER JOIN projects p ON d.dept_id = p.dept_id
ORDER BY e.emp_name;

Notice that Alice, Bob and Frank each appear twice in this result, because Engineering has two projects. That's correct here, but it's the same row multiplication that gives you inflated totals when you SUM() or COUNT() across a one-to-many join. If your numbers look too big, check for this first.

Multiple Table JOIN Flow employees departments projects dept_id dept_id Each JOIN creates an intermediate result that feeds the next JOIN

JOIN Types Comparison

JOIN Type Left Table Rows Right Table Rows Use Case
INNER JOIN Only matched Only matched Find related records
LEFT JOIN All rows Only matched Keep all left + find matches
RIGHT JOIN Only matched All rows Keep all right + find matches
FULL OUTER All rows All rows Combine everything
CROSS JOIN All × All All × All Generate combinations
SELF JOIN Table joined to itself Hierarchies, comparisons

Performance Tips for JOINs

1

Index the JOIN Columns

Put indexes on the columns in your ON clauses, usually the foreign keys. Without them, the database has to scan the whole table to find matches for each row. InnoDB creates an index for you when you declare a FOREIGN KEY, but our sample table doesn't have one.

2

Filter Rows You Don't Need

A selective WHERE condition means fewer rows going into the join. The optimizer usually pushes filters down on its own, but it can only use an index for them if one exists.

3

Select Only the Columns You Need

Skip SELECT * on joined queries. You pull back every column from every table, which wastes memory and network, and you get duplicate column names like two dept_ids that your ORM may silently overwrite.

4

Check the Plan with EXPLAIN

Put EXPLAIN in front of the query. In MySQL, type: ALL on a joined table means a full table scan, which is usually the missing index you're looking for.

SQL - Index Creation for JOIN Optimization
-- Create index on foreign key column
CREATE INDEX idx_emp_dept ON employees(dept_id);

-- Analyze query execution plan
EXPLAIN SELECT e.emp_name, d.dept_name
FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id;

Common JOIN Mistakes to Avoid

Mistake #1: Forgetting the ON Clause

Leave out the join condition and you get an accidental CROSS JOIN. On real tables that can mean millions of rows:

-- ❌ WRONG: this is a CROSS JOIN!
SELECT * FROM employees e, departments d;

-- ✅ CORRECT
SELECT * FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id;
Mistake #2: Ambiguous Column Names

When both tables have a column with the same name, prefix it with the table alias. MySQL refuses to guess and fails with ERROR 1052 (23000): Column 'dept_id' in field list is ambiguous:

-- ❌ WRONG: ambiguous column 'dept_id'
SELECT dept_id FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id;

-- ✅ CORRECT: say which table
SELECT e.dept_id FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id;
Mistake #3: Wrong JOIN Type for NULL Handling

INNER JOIN drops rows with a NULL join key, because NULL = NULL is never true. If those rows should still show up (like Eve), use LEFT JOIN.

Practice Exercise

Try These Queries

  1. Find all employees in the Engineering department
  2. List all departments and their employee count (include departments with 0 employees)
  3. Find employees who earn more than their manager
  4. List all possible employee-project assignments

View Solutions

-- 1. Engineering employees
SELECT e.emp_name, d.dept_name
FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id
WHERE d.dept_name = 'Engineering';

-- 2. Department employee counts
SELECT d.dept_name, COUNT(e.emp_id) AS employee_count
FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id
GROUP BY d.dept_id, d.dept_name;

-- 3. Employees earning more than their manager
SELECT e.emp_name AS employee, e.salary AS emp_salary,
       m.emp_name AS manager, m.salary AS mgr_salary
FROM employees e
INNER JOIN employees m ON e.manager_id = m.emp_id
WHERE e.salary > m.salary;

-- 4. All employee-project combinations (CROSS JOIN)
SELECT e.emp_name, p.project_name
FROM employees e
CROSS JOIN projects p;

Which JOIN I Reach For (and When I Don't)

Day to day, almost everything I write is INNER JOIN or LEFT JOIN. My rule of thumb: if a missing match means the row is meaningless (an order line without a product), use INNER. If the row still matters on its own (a user with no orders yet), use LEFT, and keep any right table filters in the ON clause.

A few cases where a JOIN isn't the right tool:

Further Reading
SQL MySQL Database INNER JOIN LEFT JOIN Joins
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications and working with complex database systems.