MySQLSELF JOIN

Self-Join in MySQL

A self-join joins a table to itself. You treat the same table as if it were two separate tables by giving each reference a different alias. Self-joins are the natural tool for querying hierarchical data, finding duplicate rows, comparing consecutive rows, or pairing items within the same table.

The Self-Join Concept

Imagine an employees table where each row has a manager_id that references another row's employee_id in the same table. To get both the employee's name and the manager's name in a single row, you join the table to itself with different aliases.

SQL
-- Aliases are REQUIRED — without them MySQL cannot distinguish the two table references
SELECT
  e.employee_id,
  e.full_name        AS employee_name,
  m.full_name        AS manager_name
FROM employees AS e           -- 'e' represents the employee
JOIN employees AS m           -- 'm' represents the manager
  ON e.manager_id = m.employee_id
ORDER BY m.full_name, e.full_name;
Note
Table aliases are not optional in a self-join — they are required. Without distinct aliases, MySQL has no way to distinguish which reference to employees is the employee and which is the manager.
Setting Up the Hierarchy Schema

SQL
CREATE TABLE employees (
  employee_id   INT          PRIMARY KEY AUTO_INCREMENT,
  full_name     VARCHAR(100) NOT NULL,
  job_title     VARCHAR(100),
  department    VARCHAR(50),
  manager_id    INT          NULL,           -- NULL for the top of the hierarchy
  salary        DECIMAL(10,2),
  FOREIGN KEY (manager_id) REFERENCES employees(employee_id)
);

INSERT INTO employees (employee_id, full_name, job_title, department, manager_id, salary)
VALUES
  (1,  'Diana Prince',   'CEO',               'Executive',  NULL, 250000),
  (2,  'Bruce Wayne',    'CTO',               'Technology',    1, 180000),
  (3,  'Clark Kent',     'CFO',               'Finance',        1, 175000),
  (4,  'Barry Allen',    'Engineering Lead',  'Technology',    2, 140000),
  (5,  'Hal Jordan',     'Engineering Lead',  'Technology',    2, 138000),
  (6,  'Arthur Curry',   'Senior Engineer',   'Technology',    4, 115000),
  (7,  'Victor Stone',   'Senior Engineer',   'Technology',    4, 112000),
  (8,  'Oliver Queen',   'Finance Analyst',   'Finance',        3,  95000);
Employee / Manager Query

SQL
-- All employees with their direct manager's name
-- CEO (Diana) has no manager, so use LEFT JOIN to include her row
SELECT
  e.employee_id,
  e.full_name       AS employee,
  e.job_title,
  e.department,
  COALESCE(m.full_name, '(No Manager)') AS manager
FROM employees AS e
LEFT JOIN employees AS m ON e.manager_id = m.employee_id
ORDER BY e.department, e.full_name;

employee

job_title

manager

Diana Prince

CEO

(No Manager)

Bruce Wayne

CTO

Diana Prince

Clark Kent

CFO

Diana Prince

Barry Allen

Engineering Lead

Bruce Wayne

Hal Jordan

Engineering Lead

Bruce Wayne

Arthur Curry

Senior Engineer

Barry Allen

Multi-Level Hierarchy Traversal

When hierarchies go more than one level deep, add one more self-join per level you want to include:

SQL
-- Three-level chain: VP → Manager → Individual Contributor
SELECT
  vp.full_name         AS vp,
  mgr.full_name        AS manager,
  ic.full_name         AS contributor,
  ic.salary
FROM employees AS ic
JOIN employees AS mgr ON ic.manager_id    = mgr.employee_id
JOIN employees AS vp  ON mgr.manager_id   = vp.employee_id
ORDER BY vp.full_name, mgr.full_name, ic.full_name;

-- Find all subordinates of Bruce Wayne (2 levels: direct + indirect reports)
SELECT
  mgr.full_name    AS manager,
  direct.full_name AS direct_report,
  COALESCE(indirect.full_name, '(none)') AS indirect_report
FROM employees AS mgr
JOIN employees AS direct   ON direct.manager_id   = mgr.employee_id
LEFT JOIN employees AS indirect ON indirect.manager_id = direct.employee_id
WHERE mgr.employee_id = 2  -- Bruce Wayne
ORDER BY direct.full_name, indirect.full_name;
Warning
For hierarchies of unknown or variable depth, fixed-level self-joins will not work. Use WITH RECURSIVE (a recursive CTE) to traverse the tree to any depth without writing a fixed number of JOINs.
Recursive CTE vs Self-Join for Hierarchies

When should you use a self-join vs a recursive CTE?

Criterion

Self-Join

Recursive CTE

Depth known and fixed

Good — simple and fast

Overkill

Depth unknown or variable

Cannot do it

Required

Performance on large trees

Fast — fixed number of index lookups

Can be slower — iterative execution

Path building (full ancestry)

Awkward — one alias per level

Natural — CONCAT path in each iteration

MySQL version required

Any version

MySQL 8.0+ for WITH RECURSIVE

Readability for 2-3 levels

Clear

More verbose than needed

SQL
-- Recursive CTE: all reports under Bruce Wayne at any depth
WITH RECURSIVE org AS (
  -- Anchor: start with Bruce Wayne
  SELECT employee_id, full_name, manager_id, job_title, 1 AS depth,
         full_name AS path
  FROM employees
  WHERE employee_id = 2

  UNION ALL

  -- Recursive: add each person whose manager is already in org
  SELECT e.employee_id, e.full_name, e.manager_id, e.job_title,
         org.depth + 1,
         CONCAT(org.path, ' -> ', e.full_name)
  FROM employees e
  JOIN org ON e.manager_id = org.employee_id
)
SELECT full_name, job_title, depth, path
FROM org
ORDER BY depth, full_name;
Comparing Salary Within Department

SQL
-- Employees who earn more than their direct manager
SELECT
  e.full_name     AS employee,
  e.salary        AS employee_salary,
  m.full_name     AS manager,
  m.salary        AS manager_salary,
  e.salary - m.salary AS premium
FROM employees AS e
JOIN employees AS m ON e.manager_id = m.employee_id
WHERE e.salary > m.salary
ORDER BY premium DESC;

-- Peer salary comparison: all pairs in the same department
-- a.id < b.id avoids symmetric duplicates and self-pairs
SELECT
  a.full_name     AS emp_a,
  a.salary        AS salary_a,
  b.full_name     AS emp_b,
  b.salary        AS salary_b,
  ABS(a.salary - b.salary) AS gap
FROM employees AS a
JOIN employees AS b
  ON  a.department  = b.department
  AND a.employee_id < b.employee_id
ORDER BY a.department, gap DESC;
Finding Duplicate Rows with Self-Join

A self-join is a classic technique for detecting duplicate data. Join the table to itself on the columns that define "same" and filter to rows where the primary keys differ.

SQL
-- Customers registered twice with the same email
SELECT
  a.customer_id AS id_1,
  b.customer_id AS id_2,
  a.email,
  a.created_at  AS registered_first,
  b.created_at  AS registered_second
FROM customers AS a
JOIN customers AS b
  ON  a.email       = b.email
  AND a.customer_id < b.customer_id   -- prevent (row1, row2) AND (row2, row1)
ORDER BY a.email;

-- Products with the same name appearing in multiple categories
SELECT
  a.product_id AS id_1,
  b.product_id AS id_2,
  a.name,
  a.category_id AS cat_1,
  b.category_id AS cat_2
FROM products AS a
JOIN products AS b
  ON  a.name       = b.name
  AND a.product_id < b.product_id
  AND a.category_id <> b.category_id;
Tip
The a.id < b.id condition prevents returning both (row1, row2) and (row2, row1), and also prevents a row from being joined to itself. Without it you get double the results plus self-pairs.
Comparing Consecutive Rows (Self-Join Alternative to LAG)

Before window functions existed, self-joins were the only way to compare a row with the previous or next row in a sequence. They remain useful in MySQL 5.7 and for understanding the underlying logic.

SQL
-- Orders within the same customer placed within 7 days of each other
SELECT
  a.customer_id,
  a.order_id   AS first_order,
  a.created_at AS first_date,
  b.order_id   AS next_order,
  b.created_at AS next_date,
  DATEDIFF(b.created_at, a.created_at) AS days_between
FROM orders AS a
JOIN orders AS b
  ON  a.customer_id = b.customer_id
  AND b.created_at  > a.created_at
  AND b.created_at  <= DATE_ADD(a.created_at, INTERVAL 7 DAY)
ORDER BY a.customer_id, a.created_at;

-- MySQL 8.0+ alternative using LAG() window function (cleaner):
SELECT
  customer_id, order_id, created_at,
  LAG(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) AS prev_order_date,
  DATEDIFF(created_at, LAG(created_at) OVER (PARTITION BY customer_id ORDER BY created_at)) AS days_since_last
FROM orders;
Bill of Materials Pattern

A bill of materials (BOM) table describes parts and their sub-parts — a classic self-referencing structure used in manufacturing, product catalogs, and software dependency trees.

SQL
CREATE TABLE parts (
  part_id     INT PRIMARY KEY AUTO_INCREMENT,
  name        VARCHAR(100) NOT NULL,
  parent_id   INT NULL,
  quantity    INT DEFAULT 1,
  FOREIGN KEY (parent_id) REFERENCES parts(part_id)
);

INSERT INTO parts VALUES
  (1, 'Bicycle',        NULL, 1),
  (2, 'Frame',          1,    1),
  (3, 'Wheel Assembly', 1,    2),
  (4, 'Tire',           3,    1),
  (5, 'Rim',            3,    1),
  (6, 'Spokes',         5,   32);

-- List each part with its direct parent
SELECT
  child.name     AS part,
  parent.name    AS component_of,
  child.quantity
FROM parts AS child
LEFT JOIN parts AS parent ON child.parent_id = parent.part_id
ORDER BY parent.name, child.name;

-- Recursive CTE: all components of a bicycle at every level
WITH RECURSIVE bom AS (
  SELECT part_id, name, parent_id, quantity, 0 AS level
  FROM parts WHERE part_id = 1
  UNION ALL
  SELECT p.part_id, p.name, p.parent_id, p.quantity, bom.level + 1
  FROM parts p JOIN bom ON p.parent_id = bom.part_id
)
SELECT CONCAT(REPEAT('  ', level), name) AS component, quantity FROM bom;
Finding Items Frequently Bought Together

SQL
-- Products that appear together in the same order
-- Self-join order_items to find co-purchased pairs
SELECT
  a.product_id AS product_1,
  b.product_id AS product_2,
  COUNT(*)     AS times_bought_together
FROM order_items AS a
JOIN order_items AS b
  ON  a.order_id   = b.order_id
  AND a.product_id < b.product_id   -- avoid duplicates and self-pairs
GROUP BY a.product_id, b.product_id
HAVING COUNT(*) >= 5                -- only meaningful co-purchase pairs
ORDER BY times_bought_together DESC
LIMIT 20;
Practical Self-Join Patterns

Use Case

Join Condition

Notes

Employee / manager

e.manager_id = m.employee_id

Use LEFT JOIN to include top-level rows with no parent

Find duplicates

a.email = b.email AND a.id < b.id

a.id < b.id prevents symmetric pairs and self-joins

Peer comparison

a.dept = b.dept AND a.id < b.id

Same principle — unique unordered pairs only

Sequential events

a.customer_id = b.cust_id AND b.date > a.date

Add date range to limit window size

Multi-level hierarchy

child.mgr_id = parent.id (repeated)

One JOIN per level; use WITH RECURSIVE for unknown depth

Bill of materials

child.parent_id = parent.part_id

Combine with recursive CTE for arbitrary depth

Co-purchase pairs

a.order_id = b.order_id AND a.pid < b.pid

Count occurrences with GROUP BY + HAVING

Detecting Islands — Rows That Break a Sequence

An "island" is a group of consecutive rows. A self-join detects where a sequence breaks (a gap between islands):

SQL
-- Find employee ID gaps (IDs that exist without a next consecutive ID)
SELECT a.employee_id AS gap_start, MIN(b.employee_id) - 1 AS gap_end
FROM employees AS a
JOIN employees AS b ON b.employee_id > a.employee_id
WHERE NOT EXISTS (
  SELECT 1 FROM employees c
  WHERE c.employee_id = a.employee_id + 1
)
GROUP BY a.employee_id
ORDER BY a.employee_id;

-- Simpler: find pairs where there is no row between them
SELECT a.employee_id, b.employee_id AS next_id,
       b.employee_id - a.employee_id - 1 AS gap_size
FROM employees AS a
JOIN employees AS b ON b.employee_id = (
  SELECT MIN(e.employee_id) FROM employees e
  WHERE e.employee_id > a.employee_id
)
WHERE b.employee_id - a.employee_id > 1;
Org Chart Path Building

A recursive CTE can build the full ancestry path for each employee — useful for org charts and breadcrumb navigation:

SQL
-- Build the full reporting chain path for every employee
WITH RECURSIVE path AS (
  -- Anchor: top-level employees (no manager)
  SELECT employee_id, full_name, manager_id,
         full_name AS chain,
         0 AS depth
  FROM employees WHERE manager_id IS NULL

  UNION ALL

  -- Recursive: append employee name to the chain
  SELECT e.employee_id, e.full_name, e.manager_id,
         CONCAT(p.chain, ' > ', e.full_name),
         p.depth + 1
  FROM employees e
  JOIN path p ON e.manager_id = p.employee_id
)
SELECT employee_id, full_name, depth, chain AS reporting_path
FROM path
ORDER BY chain;
Performance Considerations

Self-joins use indexes just like regular joins. The optimizer treats each alias as a separate table access. To maximize performance:

SQL
-- Index the join column (manager_id) on the employees table
CREATE INDEX idx_manager_id ON employees (manager_id);

-- EXPLAIN verifies index usage
EXPLAIN
SELECT e.full_name AS employee, m.full_name AS manager
FROM employees AS e
LEFT JOIN employees AS m ON e.manager_id = m.employee_idG
-- type for 'm': eq_ref (uses PRIMARY KEY on employee_id)
-- type for 'e': ALL or ref depending on query predicates

-- For finding duplicates, index the column you join on
CREATE INDEX idx_email ON customers (email);

EXPLAIN
SELECT a.customer_id, b.customer_id, a.email
FROM customers AS a
JOIN customers AS b ON a.email = b.email AND a.customer_id < b.customer_idG
-- type: ref (uses idx_email for the inner table)
  • Always assign different aliases to each reference of the same table

  • Use LEFT JOIN for hierarchies where top-level rows have no parent

  • Add a.id < b.id to avoid symmetric duplicates in symmetric comparisons

  • Use recursive CTEs for hierarchies of unknown or variable depth (MySQL 8.0+)

  • Self-joins benefit from indexes just like regular joins — index the join columns (especially manager_id, parent_id)

  • For row-by-row comparisons in MySQL 8.0+, LAG() and LEAD() window functions are cleaner than self-joins

  • For bill-of-materials or unlimited-depth category trees, always use WITH RECURSIVE — fixed-depth self-joins break when new levels are added