SQL SELF JOIN
A SELF JOIN is not a special SQL keyword — it’s simply a regular join where a table is joined to itself. You give the same table two different aliases so the database engine treats each alias as an independent copy of the table for the length of the query, letting you compare rows in a table against other rows from that very same table. This is the standard technique for querying hierarchical data (like an employees table whose manager_id column points back to another row’s primary key) and for finding relationships between rows — pairs, duplicates, or comparisons — that a join between two different tables can’t express. By the end of this lesson you’ll be able to write self joins for hierarchies, peer comparisons, and value comparisons, and know how to avoid the two mistakes almost everyone makes the first time.
Overview: How a Self Join Works
Every JOIN pairs rows from one row source with rows from another row source based on a matching condition. Normally those two row sources are two different tables — orders matched to customers, for example. A self join uses the same table as both row sources. Because referring to the table’s name twice in one query would be ambiguous — the engine wouldn’t know which “copy” a bare column name belongs to — you must alias the table twice, for example employees a and employees b. From that point on, a and b behave as two completely separate row sources, even though they’re reading from the same underlying data.
Internally, the query planner doesn’t know or care that both row sources happen to come from the same table file. It plans and executes a self join exactly like it would any other join: it prepares each row source (using an index if one exists on the join column), evaluates the ON condition for candidate row pairs, and applies the join type — INNER JOIN, LEFT JOIN, and so on — to decide which pairs (and which unmatched rows) survive into the result set. If the join column is indexed — a foreign key like manager_id, or the primary key it references — the engine can look up matches directly instead of comparing every row of a against every row of b.
Self joins show up constantly in real schemas:
- Hierarchies / adjacency lists — an employee row pointing to its manager’s row, a category pointing to its parent category, a comment pointing to the comment it replies to.
- Peer comparisons — finding two rows that share an attribute, like coworkers in the same department or products in the same category.
- Value comparisons — comparing a row to another row by some measure, like which product costs more than another in the same category.
- Duplicate detection — finding two different rows (different primary keys) that share the same value in some other column.
Syntax
The general shape is a normal join where both sides name the same table under different aliases:
SELECT a.column1, b.column2
FROM table_name AS a
JOIN table_name AS b
ON a.linking_column = b.linking_column
WHERE a.id_column <> b.id_column;
| Part | Meaning |
|---|---|
table_name AS a / table_name AS b |
The same table given two different aliases, so the engine — and you — can tell the two “copies” apart |
ON a.linking_column = b.linking_column |
The condition that relates a row in one copy to a row in the other, e.g. a.manager_id = b.employee_id |
| Join type | INNER JOIN (the default for JOIN) keeps only matched pairs; LEFT JOIN keeps every row from a even when there’s no match in b |
WHERE a.id_column <> b.id_column |
Stops a row from being paired with itself; use < instead of <> when you want each unordered pair only once (more on this below) |
Here’s that pattern as a small, runnable example. Each person optionally has a friend_id pointing at another row in the same table:
CREATE TABLE people (
person_id INTEGER PRIMARY KEY,
person_name TEXT,
friend_id INTEGER
);
INSERT INTO people (person_id, person_name, friend_id) VALUES
(1, 'Sam', 2),
(2, 'Tia', 3),
(3, 'Ravi', NULL);
SELECT a.person_name AS person, b.person_name AS friend
FROM people a
JOIN people b ON a.friend_id = b.person_id;
Result:
| person | friend |
|---|---|
| Sam | Tia |
| Tia | Ravi |
Ravi never appears in the person column, because his friend_id is NULL — there’s nothing for it to match on the b side, so INNER JOIN drops that row entirely.
Examples
Example 1: A Hierarchy — Employees and Their Managers
The classic self join is an adjacency-list hierarchy, where each row can point to another row in the same table as its “parent.” Here, staff.manager_id refers back to another row’s staff_id:
CREATE TABLE staff (
staff_id INTEGER PRIMARY KEY,
staff_name TEXT,
manager_id INTEGER
);
INSERT INTO staff (staff_id, staff_name, manager_id) VALUES
(1, 'Alice', NULL),
(2, 'Bob', 1),
(3, 'Carol', 1),
(4, 'Dave', 2),
(5, 'Eve', 2);
SELECT e.staff_name AS employee, m.staff_name AS manager
FROM staff e
LEFT JOIN staff m ON e.manager_id = m.staff_id
ORDER BY e.staff_id;
Result:
| employee | manager |
|---|---|
| Alice | NULL |
| Bob | Alice |
| Carol | Alice |
| Dave | Bob |
| Eve | Bob |
The alias e stands for “employee side,” and m for “manager side” — both read from staff. A LEFT JOIN is used deliberately: Alice has no manager (manager_id is NULL), so with an INNER JOIN her row would disappear entirely. The LEFT JOIN keeps her row and simply shows NULL for manager.
Example 2: Finding Coworkers in the Same Department
This example uses this course’s shared sample employees table (id, name, department, salary, hire_date), which has Alice and Carol in Engineering, Bob in Sales, and Dave in Marketing. To list pairs of employees who share a department — without pairing anyone with themselves, and without listing each pair twice — self join on department and add a.id < b.id:
SELECT a.name AS employee_1, b.name AS employee_2, a.department
FROM employees a
JOIN employees b ON a.department = b.department AND a.id < b.id
ORDER BY a.department, a.id, b.id;
Result:
| employee_1 | employee_2 | department |
|---|---|---|
| Alice | Carol | Engineering |
Only one row comes back. Alice (id 1) and Carol (id 3) are the only two employees who share a department, so they’re the only pair that satisfies a.department = b.department. Bob (Sales) and Dave (Marketing) are each alone in their department, so they never find a partner and — because this is an INNER JOIN — they’re simply absent from the result, not shown with a blank partner.
Example 3: Comparing Prices Within a Category
Self joins are also useful for comparing values row-to-row. Using the shared products table (product_id, product_name, price, category), which holds Widget in Hardware, and Gadget and Gizmo both in Electronics, this query finds, for every product, which other products in the same category cost less:
SELECT a.product_name AS pricier_item, b.product_name AS cheaper_item,
a.category, ROUND(a.price - b.price, 2) AS price_difference
FROM products a
JOIN products b ON a.category = b.category AND a.price > b.price
ORDER BY a.category, price_difference;
Result:
| pricier_item | cheaper_item | category | price_difference |
|---|---|---|---|
| Gadget | Gizmo | Electronics | 5.49 |
Widget is alone in Hardware, so it never appears — there’s no other Hardware row to compare it to. Gadget (19.99) is more expensive than Gizmo (14.50) in Electronics, so that pair satisfies a.price > b.price and shows up with the price difference computed directly in the SELECT list.
How It Works Step by Step (Under the Hood)
Take Example 2’s query and walk through SQL’s logical processing order:
- FROM — the engine sets up two row sources, both reading from
employees, labeledaandb. Conceptually this is a cross product: every row ofapaired with every row ofb(4 × 4 = 16 candidate pairs for a 4-row table). - ON — the join condition
a.department = b.department AND a.id < b.idis evaluated for each candidate pair, keeping only the ones where both parts are true. Out of 16 candidate pairs, only(Alice, Carol)survives. - JOIN type — since this is an
INNER JOIN, rows fromathat matched nothing (Bob, Dave) are simply dropped, not padded withNULLs the way aLEFT JOINwould pad them. - SELECT — the surviving pair’s columns are projected into
employee_1,employee_2, anddepartment. - ORDER BY — the result row is sorted last, after everything above has already determined which rows exist.
In practice, SQLite doesn’t literally build all 16 pairs and then filter — if department or id is indexed, it can jump straight to matching rows. But logically, thinking of a self join as “cross product, then filter by the ON condition” is exactly the right mental model, and it’s why the ON condition is what determines whether you get a hierarchy, a peer list, or an accidental explosion of rows.
Common Mistakes
Mistake 1: Forgetting to Alias the Table
If you join a table to itself but reference a column without qualifying which copy it comes from, the engine can’t tell which instance you mean:
SELECT name, department
FROM employees
JOIN employees ON department = department;
This fails with an ambiguous column name error — there are now two unaliased row sources both called employees, and name and department could each refer to either one’s column. The fix is always to alias both sides and qualify every column reference:
SELECT a.name AS employee_1, b.name AS employee_2, a.department
FROM employees a
JOIN employees b ON a.department = b.department AND a.id < b.id;
Mistake 2: Using <> Instead of < When Finding Pairs
It’s tempting to prevent an employee from being paired with themselves using a.id <> b.id. It works, but not the way most people expect:
SELECT a.name AS employee_1, b.name AS employee_2, a.department
FROM employees a
JOIN employees b ON a.department = b.department AND a.id <> b.id
ORDER BY a.department, a.id, b.id;
Result:
| employee_1 | employee_2 | department |
|---|---|---|
| Alice | Carol | Engineering |
| Carol | Alice | Engineering |
The same pair now appears twice, once in each direction, because <> is satisfied by both (1, 3) and (3, 1). This is technically correct SQL — it just isn’t what you usually want when the pair is unordered (e.g. “who are coworkers” doesn’t care which name is listed first). Swapping in a.id < b.id, as in Example 2, keeps only one direction of each pair.
Best Practices
- Always alias both instances of the table so every column reference is unambiguous — many databases will refuse to run an unaliased self join at all.
- Use
INNER JOINwhen you only want matched pairs, andLEFT JOINwhen you need to keep rows that have no match, such as a top-level row with no parent. - Use
a.id < b.id(nota.id <> b.id) whenever you’re looking for unordered pairs, to avoid double-counting each relationship in both directions. - Once the join’s purpose is clear from context, give the aliases meaningful names (
employee/manager) instead of barea/b— it makes the query much easier to read months later. - Index the linking column (a foreign key like
manager_id, or a column you frequently self join on, likedepartment) so large tables don’t force a full scan-and-compare for every row pair. - For hierarchies deeper than one or two levels, prefer a recursive CTE (
WITH RECURSIVE) over chaining multiple self joins — it scales to any depth without adding a new join for every level.
Practice Exercises
Exercise 1: Using the staff table from Example 1, write a query that returns each manager’s name along with how many direct reports they have. Hint: self join staff to itself on manager_id = staff_id, then group by the manager’s name. You should get Alice with 2 direct reports and Bob with 2 direct reports.
Exercise 2: Using the shared products table, write a self join that lists every pair of products that are in different categories, along with the absolute price difference between them. Hint: change the ON condition to a.category <> b.category instead of matching categories, and remember you’ll need something like a.product_id < b.product_id to avoid duplicate mirrored pairs.
Exercise 3: Using the shared employees table, write a self join query that returns each employee’s name next to the number of other employees in their same department (not counting themselves). Hint: self join on department with a.id <> b.id, then GROUP BY the employee in the a role. Employees who are alone in their department should show a count of 0 — think about whether an inner join can produce that row at all, or whether you need something else.
Summary
- A SELF JOIN is a normal join where a table is joined to itself, using two aliases so every column reference stays unambiguous.
- It’s the standard way to query hierarchies (employee/manager, category/parent), peer relationships (coworkers, similar products), and row-to-row comparisons.
LEFT JOINkeeps rows with no match, such as a top-of-hierarchy row;INNER JOINkeeps only rows that found a partner.- Use
a.id < b.idinstead ofa.id <> b.idto avoid getting each unordered pair twice, once in each direction. - The database engine plans and runs a self join exactly like any other join — indexing the linking column keeps it fast on large tables.
- For deep, multi-level hierarchies, a recursive CTE is usually clearer and more scalable than chaining several self joins together.
