Understanding Different Types of SQL JOINs

Databases store data in separate, well-structured tables. But real questions rarely live in a single table — they span employees and departments, orders and customers, products and categories. SQL JOINs are the mechanism that lets you stitch these tables together: cleanly, powerfully, and with surgical precision.

Whether you are building reports, powering dashboards, or engineering complex data pipelines, understanding JOINs is non-negotiable. The JOIN operation is at the heart of relational database design — and knowing which type to use, and when, is what separates a proficient SQL developer from one who only writes basic queries.

This guide covers all six major JOIN types from the ground up, with syntax, output tables, real-world examples, and performance considerations. By the end, choosing the right JOIN will feel completely natural.

All examples in this guide use two tables: Employees and Departments. The deliberate asymmetry — Diana has no department, and HR has no employees — makes every JOIN type’s behaviour immediately visible.

-- Employees table
CREATE TABLE Employees (
emp_id INT PRIMARY KEY,
name VARCHAR(50),
dept_id INT -- NULL = no department assigned
);
-- Departments table
CREATE TABLE Departments (
dept_id INT PRIMARY KEY,
dept_name VARCHAR(50)
);
-- Sample data
INSERT INTO Employees VALUES
(1, 'Alice', 10),
(2, 'Bob', 20),
(3, 'Charlie', 30),
(4, 'Diana', NULL); -- no department
INSERT INTO Departments VALUES
(10, 'Engineering'),
(20, 'Marketing'),
(40, 'HR'); -- no employees yet

OUTPUT: Employees table

emp_idnamedept_id
1Alice10
2Bob20
3Charlie30
4DianaNULL

OUTPUT: Departments table

dept_iddept_name
10Engineering
20Marketing
40HR

The table below summarises all six JOIN types, what rows they produce, and their primary use cases.

JOIN TypeLeft Table RowsRight Table RowsBest Used For
INNER JOINMatched onlyMatched onlyFetch only rows with a match in both tables
LEFT JOINAll rowsMatched or NULLKeep all left rows; expose gaps on the right
RIGHT JOINMatched or NULLAll rowsKeep all right rows; expose gaps on the left
FULL OUTER JOINAll rowsAll rowsFull reconciliation — surface all gaps both ways
CROSS JOINAll x AllAll x AllCartesian product — every combination of rows
SELF JOINSame table (alias)Same table (alias)Hierarchies and intra-table comparisons

The INNER JOIN is the most common JOIN type. It acts like a set intersection — only rows that have a matching value in both tables are included. Rows that have no match on either side are silently excluded from the result.

Tip: Writing JOIN without any keyword defaults to INNER JOIN in all major databases: PostgreSQL, MySQL, SQL Server, and SQLite.

SELECT
e.name AS employee,
d.dept_name AS department
FROM Employees e
INNER JOIN Departments d
ON e.dept_id = d.dept_id;
-- Only rows where e.dept_id matches d.dept_id are returned
-- Diana (NULL dept_id) and HR (no employees) do NOT appear

OUTPUT: INNER JOIN result — only matched rows from both tables

employeedepartment
AliceEngineering
BobMarketing
Charliedept_id=30, no match — excluded
Charlie has dept_id=30, which does not exist in the Departments table. INNER JOIN excludes him. Diana’s dept_id is NULL so she is also excluded. HR has no matching employees so it is excluded too.

LEFT JOIN keeps every row from the left table — the one listed before the JOIN keyword. Where no matching row exists in the right table, the right-side columns are filled with NULL. This makes LEFT JOIN essential for finding records that don’t have a matching pair.

SELECT
e.name AS employee,
d.dept_name AS department
FROM Employees e
LEFT JOIN Departments d
ON e.dept_id = d.dept_id;
-- All 4 employees appear; Diana shows department = NULL

OUTPUT: LEFT JOIN result — all employees, NULLs for unmatched right rows

employeedepartment
AliceEngineering
BobMarketing
CharlieNULL (dept 30 not in Departments)
DianaNULL (no dept_id)

Classic Use Case: Find Employees Without a Department

SELECT e.name FROM   Employees   e LEFT JOIN Departments d   ON e.dept_id = d.dept_id WHERE  d.dept_id IS NULL;   — Returns: Diana (and Charlie, whose dept 30 doesn’t exist)
The WHERE d.dept_id IS NULL pattern is one of the most powerful uses of LEFT JOIN. It reliably finds ‘orphaned’ rows — records in the left table that have no corresponding record in the right table.

RIGHT JOIN is the exact mirror of LEFT JOIN. It keeps every row from the right table, filling left-side columns with NULL where no match exists. In practice, most SQL developers prefer to swap the table order and use LEFT JOIN for consistency — but RIGHT JOIN is equally valid.

SELECT
e.name AS employee,
d.dept_name AS department
FROM Employees e
RIGHT JOIN Departments d
ON e.dept_id = d.dept_id;
-- All 3 departments appear; HR shows employee = NULL

OUTPUT: RIGHT JOIN result — all departments, NULLs for unmatched left rows

employeedepartment
AliceEngineering
BobMarketing
NULLHR (no employees)
The query above is exactly equivalent to swapping the table order and using LEFT JOIN: FROM Departments d LEFT JOIN Employees e ON e.dept_id = d.dept_id. Choose whichever reads more naturally for your query.

FULL OUTER JOIN is the union of LEFT JOIN and RIGHT JOIN. Every row from both tables appears in the result. Where no match exists on either side, the missing columns are filled with NULL. This is the most complete view of two tables and is ideal for data auditing and reconciliation.

SELECT
e.name AS employee,
d.dept_name AS department
FROM Employees e
FULL OUTER JOIN Departments d
ON e.dept_id = d.dept_id;
-- Diana (NULL dept) AND HR (NULL employee) both appear

OUTPUT: FULL OUTER JOIN — all rows from both tables, NULLs where no match

employeedepartment
AliceEngineering
BobMarketing
CharlieNULL (dept 30 not found)
DianaNULL (no dept_id)
NULLHR (no employees)

MySQL Workaround

MySQL does not support FULL OUTER JOIN natively. Emulate it by combining a LEFT JOIN and a RIGHT JOIN with UNION:

— MySQL: emulate FULL OUTER JOIN with UNION SELECT e.name, d.dept_name FROM   Employees e LEFT JOIN Departments d ON e.dept_id = d.dept_id UNION SELECT e.name, d.dept_name FROM   Employees e RIGHT JOIN Departments d ON e.dept_id = d.dept_id;

CROSS JOIN produces the Cartesian product — every row in the left table is paired with every row in the right table. No ON condition is needed or allowed. With 4 employees and 3 departments, the result contains 4 x 3 = 12 rows.

SELECT
e.name,
d.dept_name
FROM Employees e
CROSS JOIN Departments d;
-- 4 employees x 3 departments = 12 rows
-- No ON clause is used with CROSS JOIN

OUTPUT: CROSS JOIN — every employee paired with every department (12 rows)

employeedepartment
AliceEngineering
AliceMarketing
AliceHR
BobEngineering
BobMarketing
BobHR
CharlieEngineering
CharlieMarketing
CharlieHR
DianaEngineering
DianaMarketing
DianaHR
Real-world uses: generating test data with all combinations, building scheduling grids (every employee x every time slot), or creating combinatorial matrices for reports. Never use CROSS JOIN accidentally — a forgotten ON clause silently produces a Cartesian explosion.

A SELF JOIN is not a separate keyword — it is any JOIN where a table is joined to itself using two different aliases. This technique is especially useful for hierarchical data, such as an employee-manager relationship stored in the same table.

-- Employees table with a manager_id column
-- emp_id name dept_id manager_id
-- 1 Alice 10 NULL (top-level)
-- 2 Bob 20 1 (reports to Alice)
-- 3 Charlie 30 1 (reports to Alice)
-- 4 Diana NULL 2 (reports to Bob)
SELECT
e.name AS employee,
m.name AS manager
FROM Employees e
LEFT JOIN Employees m -- same table, different alias
ON e.manager_id = m.emp_id;
-- Alice appears with manager = NULL (she has no manager)

OUTPUT: SELF JOIN — each employee paired with their manager

employeemanager
AliceNULL (top-level)
BobAlice
CharlieAlice
DianaBob

A critical but often overlooked distinction: the ON clause filters rows during the join, while WHERE filters rows after the join. For INNER JOIN, both behave identically. For OUTER JOINs, the difference is significant.

-- These are NOT equivalent for LEFT JOIN:
-- Option A: filter in ON clause (applied DURING the join)
SELECT e.name, d.dept_name
FROM Employees e
LEFT JOIN Departments d
ON e.dept_id = d.dept_id AND d.dept_name = 'Engineering';
-- Result: all 4 employees; only Alice gets a dept_name, others get NULL
-- Option B: filter in WHERE clause (applied AFTER the join)
SELECT e.name, d.dept_name
FROM Employees e
LEFT JOIN Departments d
ON e.dept_id = d.dept_id
WHERE d.dept_name = 'Engineering';
-- Result: only Alice — the WHERE eliminates all NULL rows
Rule: if you want to filter which right-table rows participate in the join while still keeping all left-table rows, put the condition in ON. If you want to filter the entire result set after the join, put it in WHERE.

JOINs can be expensive on large tables. Keeping these principles in mind will prevent performance issues as data volumes grow.

Index Your Join Columns

Always index the columns used in ON clauses — especially foreign keys. Without an index, the database performs a full table scan for every driving row, resulting in N x M comparisons (a nested-loop scan). With an index, the engine does a fast lookup per row instead.

Watch for Cartesian Explosions

A missing ON condition on a multi-table join silently becomes a CROSS JOIN. On tables with millions of rows, this can produce billions of output rows and crash a query. Always double-check join conditions when result counts look unexpectedly large.

Filter Early

— Push filters into subqueries to reduce the join input size SELECT e.name, d.dept_name FROM   Employees e INNER JOIN (   SELECT dept_id, dept_name   FROM   Departments   WHERE  dept_name != ‘HR’   — filter BEFORE the join ) d ON e.dept_id = d.dept_id;

Prefer INNER JOIN When Possible

INNER JOIN is typically faster than OUTER JOINs because the optimizer can exclude non-matching rows early in the execution plan. Use OUTER JOINs only when you genuinely need the unmatched rows.

MistakeWhat Goes WrongCorrect Approach
Using WHERE to filter an OUTER JOIN resultConverts OUTER JOIN to INNER JOIN — eliminates the NULL rowsMove the filter into the ON clause instead of WHERE
Forgetting ON on a multi-table joinSilently becomes a CROSS JOIN — billions of rowsAlways verify every join has an ON condition
JOINing on a non-indexed columnFull table scan per row — extremely slow at scaleAdd an index on the join column (esp. foreign keys)
Using CROSS JOIN instead of INNER JOINReturns far more rows than expectedAdd an ON condition — CROSS JOIN has no filter
Selecting non-aggregated columns after GROUP BYERROR or wrong result depending on the databaseInclude all non-aggregate columns in GROUP BY
Ambiguous column names across joined tablesERROR: column reference is ambiguousAlways qualify column names with table alias: e.name

JOIN TypeSyntax KeywordON Required?Rows ReturnedNULL Fills?
INNER JOININNER JOIN or JOINYesOnly matched rowsNo
LEFT JOINLEFT JOINYesAll left + matched rightRight side
RIGHT JOINRIGHT JOINYesAll right + matched leftLeft side
FULL OUTER JOINFULL OUTER JOINYesAll rows from both tablesBoth sides
CROSS JOINCROSS JOINNoAll left x All right (N x M)No
SELF JOINAny JOIN + aliasesYesDepends on join type chosenDepends

The logical model behind SQL JOINs is simple: every JOIN type answers a different question about which rows from which table you want to keep. Once you have internalised that model, choosing the right JOIN becomes instinctive rather than a matter of trial and error.

Start with INNER JOIN for everyday lookups where you only want matched data. Reach for LEFT JOIN when you need to surface gaps or missing relationships. Use FULL OUTER JOIN for comprehensive auditing. Reserve CROSS JOIN for intentional combinatorics — and never by accident. Use SELF JOIN when your data hierarchy lives in a single table.

Practice on small datasets like the ones in this guide, inspect the output tables at each step, and you will build a JOIN intuition that no amount of memorisation can replicate.


Discover more from DataSangyan

Subscribe to get the latest posts sent to your email.

Leave a Reply