SQL Joins: Every Join Type with Runnable Examples and Exact Results

Learn SQL joins through verified INNER, LEFT, RIGHT, FULL, CROSS, composite-key, self, anti, and fanout-safe PostgreSQL examples.

Author: PracHub

Published: 8/14/2026

SQL Joins: Every Join Type with Runnable Examples and Exact Results

By PracHub
August 14, 2026
26 min read
0
SQL Joins: Every Join Type with Runnable Examples and Exact Results

Quick Overview

A Data Analyst guide to SQL join row preservation and grain. Nine verified PostgreSQL examples cover the major join shapes, ON versus WHERE, composite keys, self joins, anti joins, and pre-aggregation that prevents fanout errors.

Data AnalystFree

A join evaluates pairs of rows against an ON condition. The join type decides which unmatched rows are preserved, while key uniqueness decides how many matches each row can produce.

Predict two things before running a join: which input population must survive, and what one output row represents. That catches both silent row loss and silent row multiplication.

Row preservation chooses INNER, LEFT, or RIGHT

INNER JOIN keeps only matching employee-department pairs. Femi has no department key, and Legal has no employee, so neither appears.

Input: employees

emp_idemp_namedept_idmanager_idsalary
1Ana10NULL190000
2Ben101140000
3Cara201120000
4Dev20395000
5Elena303110000
6FemiNULL1105000

Input: departments

dept_iddept_namefloor
10Engineering3
20Sales2
30Marketing2
40Legal5
SELECT
  e.emp_name,
  e.dept_id,
  d.dept_name
FROM employees AS e
JOIN departments AS d
  ON d.dept_id = e.dept_id
ORDER BY e.emp_id;
Row flow for an inner join Six employees are matched to four departments by department ID, five matching employee rows survive, and unmatched Femi and Legal do not appear. 6 employees + 4 deptsmatch dept_id5 true pairsdiscard unmatched sides5 output rowsmatched employees only
A NULL join key does not match an ordinary equality condition, so Femi is not paired with a department.

Output

emp_namedept_iddept_name
Ana10Engineering
Ben10Engineering
Cara20Sales
Dev20Sales
Elena30Marketing

LEFT JOIN preserves every left row. This query also demonstrates filter placement: the salary condition belongs in ON because employees are optional matches and every department must remain.

Input: departments

dept_iddept_namefloor
10Engineering3
20Sales2
30Marketing2
40Legal5

Input: employees

emp_idemp_namedept_idmanager_idsalary
1Ana10NULL190000
2Ben101140000
3Cara201120000
4Dev20395000
5Elena303110000
6FemiNULL1105000
SELECT
  d.dept_name,
  e.emp_name
FROM departments AS d
LEFT JOIN employees AS e
  ON e.dept_id = d.dept_id
 AND e.salary > 100000
ORDER BY d.dept_id, e.emp_id;
Row flow for a filtered left join Four departments match only employees above 100000, producing four employee matches plus one null-padded Legal row. 4 departmentspreserved population4 eligible matchessalary filter in ON5 output rowsLegal padded with NULL
Moving the salary predicate to WHERE would remove Legal after the join and change the preserved population.

Output

dept_nameemp_name
EngineeringAna
EngineeringBen
SalesCara
MarketingElena
LegalNULL

The SQL order-of-operations guide explains why ON controls matching before WHERE filters the joined rows.

RIGHT JOIN is the mirror: it preserves the right table. It can always be rewritten as a left join with the table order swapped, but understanding the written form helps when reviewing existing SQL.

Input: employees

emp_idemp_namedept_idmanager_idsalary
1Ana10NULL190000
2Ben101140000
3Cara201120000
4Dev20395000
5Elena303110000
6FemiNULL1105000

Input: departments

dept_iddept_namefloor
10Engineering3
20Sales2
30Marketing2
40Legal5
SELECT
  e.emp_name,
  d.dept_id,
  d.dept_name
FROM employees AS e
RIGHT JOIN departments AS d
  ON e.dept_id = d.dept_id
ORDER BY d.dept_id, e.emp_id;
Row flow for a right join Employees match departments by department ID, every right-side department survives, and Legal receives one null employee row. 4 right-side deptsall preserved5 employee matchesFemi unmatched left6 output rowsLegal added with NULL
Right preservation keeps Legal and discards unmatched Femi, exactly opposite the left-preserved employee view.

Output

emp_namedept_iddept_name
Ana10Engineering
Ben10Engineering
Cara20Sales
Dev20Sales
Elena30Marketing
NULL40Legal

FULL OUTER and CROSS JOIN solve different shapes

FULL OUTER JOIN preserves unmatched rows from both inputs. It is useful for reconciliation because it exposes missing records on either side.

Input: employees

emp_idemp_namedept_idmanager_idsalary
1Ana10NULL190000
2Ben101140000
3Cara201120000
4Dev20395000
5Elena303110000
6FemiNULL1105000

Input: departments

dept_iddept_namefloor
10Engineering3
20Sales2
30Marketing2
40Legal5
SELECT
  e.emp_name,
  d.dept_name
FROM employees AS e
FULL OUTER JOIN departments AS d
  ON e.dept_id = d.dept_id
ORDER BY d.dept_id NULLS LAST, e.emp_id;
Row flow for a full outer join Five employee-department matches are combined with unmatched Legal from the department side and unmatched Femi from the employee side, producing seven rows. 5 matched pairsdept_id equalityAdd both unmatched sidesLegal + Femi7 output rowstwo mismatch types
A full outer join aligns sources side by side; it does not stack them as a set operation would.

Output

emp_namedept_name
AnaEngineering
BenEngineering
CaraSales
DevSales
ElenaMarketing
NULLLegal
FemiNULL

CROSS JOIN does not search for matching keys. It returns every combination. A small dimension such as quarters can create a reporting scaffold that later receives actual measures.

Input: departments

dept_iddept_namefloor
10Engineering3
20Sales2
30Marketing2
40Legal5

Input: quarters

quarter
Q1
Q2
SELECT
  d.dept_name,
  q.quarter
FROM departments AS d
CROSS JOIN quarters AS q
ORDER BY d.dept_id, q.quarter;
Row flow for a department-quarter cross join Four department rows pair with two quarter rows without an on condition, producing eight combinations. 4 departments× 2 quartersEvery combinationno ON condition8 scaffold rowscomplete grid
The expected row count is the product of the input counts: four times two equals eight.

Output

dept_namequarter
EngineeringQ1
EngineeringQ2
SalesQ1
SalesQ2
MarketingQ1
MarketingQ2
LegalQ1
LegalQ2

Join keys must match table grain

Budgets and spend are each one row per department and fiscal year. Joining on department alone would mix years. The full composite key preserves the intended department-year grain, and LEFT JOIN retains a budget whose spend is not recorded.

Input: budgets

dept_idfiscal_yearbudget_amount
102025500000
102026550000
202025300000
202026280000
302026120000

Input: spend

dept_idfiscal_yearspend_amount
102025480000
102026505000
202025310000
302026118000
SELECT
  b.dept_id,
  b.fiscal_year,
  b.budget_amount,
  s.spend_amount
FROM budgets AS b
LEFT JOIN spend AS s
  ON s.dept_id = b.dept_id
 AND s.fiscal_year = b.fiscal_year
ORDER BY b.dept_id, b.fiscal_year;
Row flow for a composite-key budget join Five budget rows match four spend rows on department and fiscal year together, preserving all five budget rows and one null spend. 5 budget rowsdept-year grainMatch both keys4 spend matches5 aligned rowsone missing spend
A key must distinguish rows on both sides. Matching only department would create false cross-year pairs.

Output

dept_idfiscal_yearbudget_amountspend_amount
102025500000480000
102026550000505000
202025300000310000
202026280000NULL
302026120000118000

Self, semi, and anti joins

A self join gives one table two roles. Employees are the left role; managers are another reference to the same table. LEFT JOIN keeps Ana, who has no manager.

Input: employees

emp_idemp_namedept_idmanager_idsalary
1Ana10NULL190000
2Ben101140000
3Cara201120000
4Dev20395000
5Elena303110000
6FemiNULL1105000
SELECT
  e.emp_name AS employee,
  m.emp_name AS manager
FROM employees AS e
LEFT JOIN employees AS m
  ON m.emp_id = e.manager_id
ORDER BY e.emp_id;
Row flow for an employee-manager self join Six employee rows are matched to the same table through manager ID, five manager matches are found, and Ana remains with a null manager. 6 employee rowsemployee roleMatch manager_idsame table, manager role6 output rowsAna manager = NULL
Aliases identify the two roles; the join mechanics are otherwise unchanged.

Output

employeemanager
AnaNULL
BenAna
CaraAna
DevCara
ElenaCara
FemiAna

A semi-join keeps a left row when any match exists; EXISTS expresses it without emitting or duplicating right rows. An anti-join keeps a left row when no match exists; NOT EXISTS expresses that inverse safely even when the right-side join column contains NULL.

Input: departments

dept_iddept_namefloor
10Engineering3
20Sales2
30Marketing2
40Legal5

Input: employees

emp_idemp_namedept_idmanager_idsalary
1Ana10NULL190000
2Ben101140000
3Cara201120000
4Dev20395000
5Elena303110000
6FemiNULL1105000
SELECT d.dept_name
FROM departments AS d
WHERE NOT EXISTS (
  SELECT 1
  FROM employees AS e
  WHERE e.dept_id = d.dept_id
)
ORDER BY d.dept_id;
Row flow for an anti-join with NOT EXISTS Four departments are checked for employee matches, three have at least one match and are excluded, leaving Legal. 4 departmentstest each key3 have employeesNOT EXISTS rejects them1 unmatched rowLegal
Femi's NULL department does not poison the result; this differs from a nullable NOT IN subquery.

Output

dept_name
Legal

The NULL comparison guide explains why NOT IN needs special care when its list or subquery can contain NULL.

Control fanout before aggregating

Ana owns two projects. Joining raw projects would repeat her salary twice. First collapse projects to one row per employee, then join at employee grain and aggregate to department grain.

Input: departments

dept_iddept_namefloor
10Engineering3
20Sales2
30Marketing2
40Legal5

Input: employees

emp_idemp_namedept_idmanager_idsalary
1Ana10NULL190000
2Ben101140000
3Cara201120000
4Dev20395000
5Elena303110000
6FemiNULL1105000

Input: projects

project_idemp_idhours
100110
101120
102215
10355
WITH project_hours AS (
  SELECT emp_id, SUM(hours) AS total_hours
  FROM projects
  GROUP BY emp_id
)
SELECT
  d.dept_name,
  COUNT(e.emp_id) AS employees_with_projects,
  SUM(e.salary) AS total_salary,
  SUM(p.total_hours) AS total_hours
FROM departments AS d
JOIN employees AS e
  ON e.dept_id = d.dept_id
JOIN project_hours AS p
  ON p.emp_id = e.emp_id
GROUP BY d.dept_id, d.dept_name
ORDER BY d.dept_id;
Row flow for controlling project fanout before payroll aggregation Four project rows collapse to three employee project totals, join one-to-one with employees, and aggregate into two departments without repeating salaries. 4 project rowsAna appears twice3 employee totalsjoin at employee grain2 department rowssalary counted once
Pre-aggregation aligns the many-side with employee grain before salary and hours are rolled up together.

Output

dept_nameemployees_with_projectstotal_salarytotal_hours
Engineering233000045
Marketing11100005

This output intentionally includes only employees who have projects. If every employee or department must appear, change the population-preserving joins and zero policy accordingly. The GROUP BY guide and SQL COUNT guide cover the aggregation choices after the join.

FAQ

Can an inner join return more rows than either input?

Yes. One left row emits one result for every matching right row. Duplicate or one-to-many keys can therefore multiply rows.

Is RIGHT JOIN different from LEFT JOIN?

Its preservation direction is reversed, but any right join can be expressed as a left join by swapping table order. Choose the form that makes the preserved population easiest to read.

Where should a right-table filter go in a LEFT JOIN?

Put it in ON when it limits eligible matches but unmatched left rows must remain. Put it in WHERE when the requirement truly removes joined rows after matching.

When should I use EXISTS instead of JOIN?

Use EXISTS when the question only asks whether a match exists and no right-side columns are needed. It preserves one output row per qualifying left row.

How do I detect join fanout?

State the expected key and row count before joining. Then compare row counts and key uniqueness after each join. If a supposed one-to-one join multiplies keys, inspect duplicates on the join columns before aggregating.


Comments (0)