A self join is a table joined to itself. It is not a separate kind of join — there is no
SELF JOIN keyword — it is an ordinary JOIN where both sides happen to name
the same table. Everything you know about
INNER and LEFT joins
applies unchanged.
The one thing that stops being optional is the alias. MySQL needs two different names to tell the
two copies apart, so FROM staff, staff is an error and FROM staff a JOIN staff
b is fine.
The classic: a row that points at another row
The pizza schema has no parent-child column, so here is a small staff table that does — each
person's manager_id refers to another row in the same table:
CREATE TABLE staff (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(80) NOT NULL,
role VARCHAR(40) NOT NULL,
manager_id BIGINT NULL,
CONSTRAINT fk_staff_manager FOREIGN KEY (manager_id) REFERENCES staff (id)
);
INSERT INTO staff (id, name, role, manager_id) VALUES
(1, 'Dana Whitfield', 'General Manager', NULL),
(2, 'Alex Rivera', 'Shift Lead', 1),
(3, 'Priya Nair', 'Shift Lead', 1),
(4, 'Sam Chen', 'Driver', 2),
(5, 'Casey Lindgren', 'Cook', 3);The foreign key points at the table it lives in, which is legal and is what makes the shape work. Now pair each employee with their manager:
SELECT e.name AS employee, e.role, m.name AS manager
FROM staff e
LEFT JOIN staff m ON m.id = e.manager_id
ORDER BY e.id;+----------------+-----------------+----------------+
| employee | role | manager |
+----------------+-----------------+----------------+
| Dana Whitfield | General Manager | NULL |
| Alex Rivera | Shift Lead | Dana Whitfield |
| Priya Nair | Shift Lead | Dana Whitfield |
| Sam Chen | Driver | Alex Rivera |
| Casey Lindgren | Cook | Priya Nair |
+----------------+-----------------+----------------+LEFT JOIN, not JOIN: the General Manager has no manager, and an inner
join would drop the person at the top of the tree. That is the usual bug in this query.
This walks one level. "Everyone under Dana, however deep" is a different problem — you cannot express an unbounded depth by adding joins. That needs a recursive CTE, or the closure table pattern.
Comparing rows within a table
The other everyday use is finding pairs. Which line items travelled together in the same order?
SELECT i1.order_id, i1.product_name AS item_a, i2.product_name AS item_b
FROM order_item i1
JOIN order_item i2 ON i2.order_id = i1.order_id AND i2.id > i1.id
ORDER BY i1.order_id, i1.id, i2.id
LIMIT 5;+----------+-------------------+-------------------+
| order_id | item_a | item_b |
+----------+-------------------+-------------------+
| 1 | Pepperoni Pizza | Pepsi |
| 3 | Meat Lovers Pizza | Iced Tea |
| 6 | BBQ Chicken Pizza | Mountain Dew |
| 8 | Pepperoni Pizza | Bottled Water |
| 11 | Supreme Pizza | Meat Lovers Pizza |
+----------+-------------------+-------------------+a.id < b.id is the whole trick
That extra condition is doing more work than it looks. Without any guard, every row matches itself, and every genuine pair appears twice — once in each direction:
SELECT
(SELECT COUNT(*) FROM order_item a JOIN order_item b ON b.order_id = a.order_id) AS no_guard,
(SELECT COUNT(*) FROM order_item a JOIN order_item b ON b.order_id = a.order_id AND b.id <> a.id) AS not_equal,
(SELECT COUNT(*) FROM order_item a JOIN order_item b ON b.order_id = a.order_id AND b.id > a.id) AS ordered_pairs;+----------+-----------+---------------+
| no_guard | not_equal | ordered_pairs |
+----------+-----------+---------------+
| 47 | 20 | 10 |
+----------+-----------+---------------+Three different answers to what sounds like one question:
- 47 — no guard. Includes each row paired with itself.
- 20 —
<>removes the self-pairs but keeps both directions, so (A, B) and (B, A) are both there. Exactly double the truth. - 10 —
>keeps one row per unordered pair. This is the answer.
Use < or > when the pair is unordered ("these two went
together") and <> when direction is meaningful ("A is a substitute for B" is not
the same claim as the reverse).
A self join needs its indexes like any other
Both copies of the table are read independently, so the join column wants an index the same way
it would across two different tables — here order_item.order_id, which the schema
already indexes. Pair-finding queries also grow quadratically within each group: a self join over
orders with a handful of items each is cheap, and one over a group of 10,000 rows is 50 million
pairs. Filter before you pair, not after.
What to remember
- A self join is an ordinary join; only the aliases become mandatory.
- Use
LEFT JOINfor parent lookups or you lose the row at the top. a.id < b.idremoves both self-pairs and mirrored duplicates.- One self join walks one level. Arbitrary depth needs a recursive CTE.