SQL SELF JOIN: Syntax, Examples & Practice
A SQL self join is a join of a table to itself. There is no SELF JOIN keyword: you write an ordinary JOIN, list the same table twice, and give each copy its own alias. It is how you relate one row of a table to another row of the same table, such as an employee to their manager.
What Is a SELF JOIN in SQL?
In the Playground's employees table, each row has a manager_id. That number is the id of another row in the same table, because managers are employees too:
SELECT id, first_name, title, manager_id
FROM employees
WHERE id IN (1, 11, 12)
ORDER BY id;
▶ Run this query in the SQL Playground
| id | first_name | title | manager_id |
|---|---|---|---|
| 1 | William | Senior CS Manager | NULL |
| 11 | Laura | Sales Manager | 1 |
| 12 | Betty | COO | 1 |
Laura and Betty both have manager_id = 1, and employee 1 is William. To show the manager's name instead of a number, the table has to be looked up against itself. Give it two aliases, e for the employee and m for the manager, and join them:
SELECT e.id, e.first_name AS employee, e.title,
m.first_name AS manager, m.title AS manager_title
FROM employees e
JOIN employees m
ON m.id = e.manager_id
WHERE e.id BETWEEN 11 AND 16
ORDER BY e.id;
▶ Run this query in the SQL Playground
| id | employee | title | manager | manager_title |
|---|---|---|---|---|
| 11 | Laura | Sales Manager | William | Senior CS Manager |
| 12 | Betty | COO | William | Senior CS Manager |
| 13 | Joshua | Content Lead | Michelle | COO |
| 14 | Donald | CHRO | Daniel | VP Engineering |
| 15 | Barbara | Senior CS Manager | Steven | Paralegal |
| 16 | Ashley | HR Manager | Michael | Senior Counsel |
The database treats e and m as two separate tables that happen to hold the same rows. Every column must be prefixed with one of the aliases, otherwise there is no way to say which copy you mean.
SELF JOIN Syntax
SELECT a.column, b.column
FROM table_name a
JOIN table_name b
ON a.some_column = b.other_column;
| Part | What it does |
|---|---|
table_name a | The first copy of the table, under the alias a. |
JOIN table_name b | The second copy of the same table, under a different alias. |
ON … | How a row of one copy relates to a row of the other. |
| Join type | Any: JOIN, LEFT JOIN and so on. "Self join" describes what is joined, not how. |
e and m, or emp and mgr.A self join works the same way in MySQL, PostgreSQL, SQL Server, Oracle and SQLite, because it is only a normal join.
Self Join With INNER JOIN vs LEFT JOIN
People at the top of the hierarchy have no manager: their manager_id is NULL, and NULL matches nothing. An inner self join drops them; a left self join keeps them.
SELECT (SELECT COUNT(*) FROM employees) AS employees,
COUNT(*) AS inner_join_rows,
(SELECT COUNT(*)
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id) AS left_join_rows
FROM employees e
JOIN employees m
ON m.id = e.manager_id;
▶ Run this query in the SQL Playground
| employees | inner_join_rows | left_join_rows |
|---|---|---|
| 120 | 110 | 120 |
Of 120 employees, 110 have a manager. The other ten only appear with a LEFT JOIN, with NULL in the manager columns:
SELECT e.id, e.first_name AS employee, e.title,
m.first_name AS manager
FROM employees e
LEFT JOIN employees m
ON m.id = e.manager_id
WHERE e.id BETWEEN 8 AND 13
ORDER BY e.id;
▶ Run this query in the SQL Playground
| id | employee | title | manager |
|---|---|---|---|
| 8 | Steven | Paralegal | NULL |
| 9 | Michael | Senior Counsel | NULL |
| 10 | Michelle | COO | NULL |
| 11 | Laura | Sales Manager | William |
| 12 | Betty | COO | William |
| 13 | Joshua | Content Lead | Michelle |
| Use | When |
|---|---|
JOIN (inner) | You only want rows that have a related row, e.g. employees who report to someone. |
LEFT JOIN | Every row must appear, e.g. a complete staff list with the manager where there is one. |
Self Join for Hierarchies
Grouping a self join by the manager side turns it around: one row per manager, with the people who report to them counted.
SELECT m.id, m.first_name, m.last_name, m.title,
COUNT(*) AS direct_reports
FROM employees e
JOIN employees m
ON m.id = e.manager_id
GROUP BY m.id, m.first_name, m.last_name, m.title
ORDER BY direct_reports DESC, m.id;
▶ Run this query in the SQL Playground
| id | first_name | last_name | title | direct_reports |
|---|---|---|---|---|
| 9 | Michael | Campbell | Senior Counsel | 14 |
| 2 | John | Miller | Paralegal | 13 |
| 7 | Barbara | Rivera | Recruiter | 13 |
| 10 | Michelle | Miller | COO | 13 |
| 8 | Steven | Baker | Paralegal | 12 |
10 people manage the other 110. Each extra level of the hierarchy needs one more copy of the table. A third alias reaches the manager's manager:
SELECT e.first_name AS employee,
m.first_name AS manager,
mm.first_name AS managers_manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id
LEFT JOIN employees mm ON mm.id = m.manager_id;
WITH RECURSIVE, which repeats the self join until no rows are left. See CTEs.Comparing Rows in the Same Table
Once two rows of a table sit side by side, their columns can be compared. A classic interview question: which employees earn more than their manager?
SELECT e.first_name || ' ' || e.last_name AS employee, e.salary,
m.first_name || ' ' || m.last_name AS manager,
m.salary AS manager_salary
FROM employees e
JOIN employees m
ON m.id = e.manager_id
WHERE e.salary > m.salary
ORDER BY e.salary - m.salary DESC
LIMIT 5;
▶ Run this query in the SQL Playground
| employee | salary | manager | manager_salary |
|---|---|---|---|
| Paul Robinson | 196032 | Melissa Smith | 51465 |
| Joshua Hall | 191657 | Melissa Smith | 51465 |
| Laura Rivera | 195759 | William Lewis | 61774 |
| Joshua Nguyen | 182684 | Melissa Smith | 51465 |
| John Lee | 203467 | Michael Campbell | 82620 |
The join pairs each employee with their manager; WHERE then compares a column from each copy. The same pattern finds employees who were hired before their manager:
SELECT e.first_name AS employee, e.hire_date,
m.first_name AS manager, m.hire_date AS manager_hired
FROM employees e
JOIN employees m
ON m.id = e.manager_id
WHERE e.hire_date < m.hire_date
ORDER BY e.hire_date
LIMIT 5;
▶ Run this query in the SQL Playground
| employee | hire_date | manager | manager_hired |
|---|---|---|---|
| Steven | 2015-01-14 | Jason | 2019-10-17 |
| Michael | 2015-02-16 | Daniel | 2016-06-06 |
| Sarah | 2015-02-26 | John | 2021-08-09 |
| John | 2015-03-19 | Michael | 2021-07-28 |
| Maria | 2015-04-24 | Barbara | 2020-05-14 |
A join condition does not have to be =
A self join can match rows on any condition. This finds repeat purchases: pairs of orders by the same customer where the second came within 30 days of the first.
SELECT a.user_id,
a.id AS first_order, a.order_date AS first_date,
b.id AS next_order, b.order_date AS next_date
FROM orders a
JOIN orders b
ON b.user_id = a.user_id
AND b.order_date > a.order_date
AND b.order_date <= date(a.order_date, '+30 days')
ORDER BY a.user_id, a.order_date;
▶ Run this query in the SQL Playground
| user_id | first_order | first_date | next_order | next_date |
|---|---|---|---|---|
| 44 | 49 | 2022-06-28 | 152 | 2022-07-21 |
| 47 | 132 | 2022-10-15 | 14 | 2022-10-16 |
| 124 | 117 | 2023-09-18 | 33 | 2023-09-23 |
Three conditions describe the pair: same customer, later date, no more than 30 days later. date(…, '+30 days') is SQLite's date arithmetic.
Finding Duplicates and Building Pairs
To find rows that share a value, join the table to itself on that value. Several products in the Playground have the same name. Matching on name alone is not enough, though, as the row counts show:
SELECT 'no id condition' AS id_condition, COUNT(*) AS rows_returned
FROM products a JOIN products b ON b.name = a.name
UNION ALL
SELECT 'a.id <> b.id', COUNT(*)
FROM products a JOIN products b ON b.name = a.name AND a.id <> b.id
UNION ALL
SELECT 'a.id < b.id', COUNT(*)
FROM products a JOIN products b ON b.name = a.name AND a.id < b.id;
▶ Run this query in the SQL Playground
| id_condition | rows_returned |
|---|---|
| no id condition | 278 |
| a.id <> b.id | 158 |
| a.id < b.id | 79 |
| Condition | Rows | What you get |
|---|---|---|
| None | 278 | Every row also matches itself, so all 120 products appear at least once |
a.id <> b.id | 158 | Real duplicates, but each pair twice: (A, B) and (B, A) |
a.id < b.id | 79 | Each pair exactly once |
So the working query uses <:
SELECT a.id AS id_a, b.id AS id_b,
a.name, a.price AS price_a, b.price AS price_b
FROM products a
JOIN products b
ON b.name = a.name
AND a.id < b.id
ORDER BY a.name, a.id
LIMIT 6;
▶ Run this query in the SQL Playground
| id_a | id_b | name | price_a | price_b |
|---|---|---|---|---|
| 61 | 102 | 2000A Jump Starter Pack | 1948.86 | 957.25 |
| 3 | 46 | 4K Dual Dash Camera | 568.89 | 73.64 |
| 3 | 72 | 4K Dual Dash Camera | 568.89 | 1229.81 |
| 46 | 72 | 4K Dual Dash Camera | 73.64 | 1229.81 |
| 17 | 50 | A5 Hardcover Notebook 3pk | 164.95 | 123.26 |
| 8 | 24 | Artisan Hot Sauce Trio | 1698.99 | 104.33 |
A name used by three products gives three pairs. If you only need to know which values are duplicated, not the pairs, GROUP BY name HAVING COUNT(*) > 1 is shorter; see HAVING.
The same < rule builds pairs of people. Colleagues in the same department and the same city:
SELECT a.first_name || ' ' || a.last_name AS employee_a,
b.first_name || ' ' || b.last_name AS employee_b,
a.department, a.city
FROM employees a
JOIN employees b
ON b.department_id = a.department_id
AND b.city = a.city
AND a.id < b.id
ORDER BY a.department, a.city, a.id
LIMIT 6;
▶ Run this query in the SQL Playground
| employee_a | employee_b | department | city |
|---|---|---|---|
| Sarah Ramirez | Mark Anderson | Customer Success | Columbus |
| William Lewis | Robert Green | Customer Success | San Diego |
| Kenneth Jackson | Dorothy Campbell | Customer Success | Seattle |
| Maria White | Michael Walker | Design | Chicago |
| Jason Mitchell | Ashley Thomas | Design | Denver |
| Jason Mitchell | Sandra Lewis | Design | Denver |
Self Join to Find the Top Row per Group
Who is the highest paid person in each department? Join each employee to any colleague in the same department who earns more. The top earner is the one for whom no such colleague exists:
SELECT e.department, e.first_name, e.last_name, e.salary
FROM employees e
LEFT JOIN employees h
ON h.department_id = e.department_id
AND h.salary > e.salary
WHERE h.id IS NULL
ORDER BY e.department;
▶ Run this query in the SQL Playground
| department | first_name | last_name | salary |
|---|---|---|---|
| Customer Success | Nancy | Thompson | 203999 |
| Design | Amy | Nguyen | 194543 |
| Engineering | Donna | Johnson | 199029 |
| Finance | Paul | Robinson | 196032 |
| HR | Ronald | Ramirez | 191037 |
| Legal | Kenneth | Campbell | 192220 |
| Marketing | Steven | Nguyen | 204449 |
| Operations | John | Lee | 203467 |
| Product | Steven | Adams | 188273 |
| Sales | Laura | Rivera | 195759 |
This is the "rows with no match" pattern from the LEFT JOIN lesson, applied to a table and itself. It works in every database, including old versions without window functions.
Self join or window function?
| Task | Self join | Window function |
|---|---|---|
| Previous or next row | Join on "the row before", which is awkward to define | LAG / LEAD, one line |
| Top row per group | Anti-join as above | ROW_NUMBER or RANK |
| Running totals | Join each row to all earlier rows: slow | SUM() OVER (ORDER BY …) |
| Relating a row to a different row by key (manager, parent, referrer) | The natural tool | Not suitable |
| Pairs and duplicates | The natural tool | Not suitable |
Use a self join when a row points at another row, or when you need pairs. Use a window function when you are comparing a row with its neighbours in some order.
Where SELF JOIN Is Used
| Question | The table points at itself through |
|---|---|
| Each employee with their manager | employees.manager_id → employees.id |
| Each category with its parent category | categories.parent_id → categories.id |
| Customers and who referred them | users.referred_by → users.id |
| Comments and the comment they reply to | comments.reply_to → comments.id |
| Parts and the assembly they belong to | parts.parent_part_id → parts.id |
| Flights that connect: arrival airport = next departure | A matching condition between two rows |
| Duplicate customers with the same email | A shared value, with a.id < b.id |
| Products often bought together | order_items joined to itself on order_id |
Common SELF JOIN Mistakes
1. No aliases
SELECT first_name, first_name
FROM employees
JOIN employees
ON id = manager_id;
SELECT e.first_name, m.first_name
FROM employees e
JOIN employees m
ON m.id = e.manager_id;
With the same table named twice, nothing distinguishes the copies. Each needs its own alias and every column its prefix.
2. The join condition the wrong way round
SELECT e.first_name AS employee,
m.first_name AS manager
FROM employees e
JOIN employees m
ON e.id = m.manager_id;
SELECT e.first_name AS employee,
m.first_name AS manager
FROM employees e
JOIN employees m
ON m.id = e.manager_id;
Both versions run. In the first, m is actually the report and e the manager, so the column labels are swapped. Say the condition aloud: "the manager's id equals the employee's manager_id".
3. Rows matching themselves
SELECT a.name
FROM products a
JOIN products b
ON b.name = a.name;
SELECT a.name
FROM products a
JOIN products b
ON b.name = a.name
AND a.id < b.id;
A row always shares its own values. Add a condition on the primary key; < also stops each pair appearing twice.
4. INNER JOIN when the top of the tree must stay
SELECT e.first_name, m.first_name
FROM employees e
JOIN employees m
ON m.id = e.manager_id;
SELECT e.first_name, m.first_name
FROM employees e
LEFT JOIN employees m
ON m.id = e.manager_id;
A NULL manager_id matches nothing. Use LEFT JOIN for a complete list.
5. Pairing on a low-cardinality column
SELECT a.id, b.id
FROM employees a
JOIN employees b
ON b.city = a.city
AND a.id < b.id;
SELECT city, COUNT(*) AS employees
FROM employees
GROUP BY city;
A group of n rows produces n × (n − 1) / 2 pairs, so 100 people in one city are 4,950 pairs. If you want a count per value, not the pairs themselves, use GROUP BY.
SELF JOIN Performance
| Tip | Why it helps |
|---|---|
| Index the pointer column | manager_id is looked up against the primary key. An index on it also speeds up "who reports to X". |
Use < rather than <> for pairs | It halves the result and removes mirrored rows. |
| Join on selective columns | Pairing rows that share a common value grows with the square of the group size. |
| Prefer window functions for neighbours and running totals | One pass over the table instead of joining every row to many others. |
| Use a recursive CTE for deep trees | One query for any depth, instead of one join per level. |
SELF JOIN Cheat Sheet
| Goal | Pattern |
|---|---|
| Row with its parent | FROM t c JOIN t p ON p.id = c.parent_id |
| Keep rows with no parent | … LEFT JOIN t p ON p.id = c.parent_id |
| Count children per parent | GROUP BY p.id with COUNT(*) |
| Compare a row with its parent | WHERE c.value > p.value |
| Duplicates, each pair once | ON b.col = a.col AND a.id < b.id |
| Top row per group | LEFT JOIN t h ON h.grp = t.grp AND h.val > t.val WHERE h.id IS NULL |
| Three levels | Add a third alias joined to the second |
Practice SELF JOIN Queries
7 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy List the first ten employees who have a manager, with their name, their title and their manager's full name. Table:
employeesShow solution
SELECT e.first_name, e.last_name, e.title, m.first_name || ' ' || m.last_name AS manager FROM employees e JOIN employees m ON m.id = e.manager_id ORDER BY e.id LIMIT 10;▶ Run this query in the SQL Playground
eis the employee andmthe manager. The inner join leaves out the ten people who have no manager. -
2Easy List the employees who have no manager, using a LEFT self join. Table:
employeesShow solution
SELECT e.id, e.first_name, e.last_name, e.title FROM employees e LEFT JOIN employees m ON m.id = e.manager_id WHERE m.id IS NULL ORDER BY e.id;▶ Run this query in the SQL Playground
The LEFT JOIN keeps everyone;
m.id IS NULLkeeps those with no matching manager row. 10 employees are at the top. -
3Medium For each manager, show their name, the number of direct reports and the average salary of those reports. Table:
employeesShow solution
SELECT m.first_name, m.last_name, COUNT(*) AS direct_reports, ROUND(AVG(e.salary)) AS avg_report_salary FROM employees e JOIN employees m ON m.id = e.manager_id GROUP BY m.id, m.first_name, m.last_name ORDER BY direct_reports DESC, m.id;▶ Run this query in the SQL Playground
Group by the manager copy and aggregate the employee copy. The salary averaged is
e.salary, the reports' pay. -
4Medium How many employees earn more than their manager? Table:
employeesShow solution
SELECT COUNT(*) AS earn_more_than_manager FROM employees e JOIN employees m ON m.id = e.manager_id WHERE e.salary > m.salary;▶ Run this query in the SQL Playground
Each row of the join is one employee beside their manager, so the two salaries can be compared directly.
-
5Medium Find employees who work in a different department from their manager. Show the employee, their department, the manager and the manager's department for the first ten. Table:
employeesShow solution
SELECT e.first_name AS employee, e.department, m.first_name AS manager, m.department AS manager_department FROM employees e JOIN employees m ON m.id = e.manager_id WHERE m.department_id <> e.department_id ORDER BY e.id LIMIT 10;▶ Run this query in the SQL Playground
The join condition links the pair; the WHERE compares one column from each copy.
-
6Challenging Find pairs of customers who share a last name and live in the same city, which may be the same household. Show each pair once. Table:
usersShow solution
SELECT a.id AS id_a, b.id AS id_b, a.first_name AS first_name_a, b.first_name AS first_name_b, a.last_name, a.city FROM users a JOIN users b ON b.last_name = a.last_name AND b.city = a.city AND a.id < b.id ORDER BY a.last_name, a.city, a.id;▶ Run this query in the SQL Playground
a.id < b.idstops a row matching itself and keeps one row per pair. 7 pairs of customers share a last name and a city. -
7Challenging Find the most expensive product in each category with a self join, not MAX or a window function. Table:
productsShow solution
SELECT p.category, p.name, p.price FROM products p LEFT JOIN products h ON h.category = p.category AND h.price > p.price WHERE h.id IS NULL ORDER BY p.category;▶ Run this query in the SQL Playground
A product is the most expensive in its category when no product in the same category has a higher price, so the LEFT JOIN finds no match.
📝 Quiz — SELF JOIN
Test your understanding · 0/10 answered
What is a self join?
A self join lists the same table twice in the FROM clause and joins the two copies.
Is SELF JOIN a keyword in SQL?
There is no SELF JOIN keyword. You write JOIN or LEFT JOIN and use the same table under two aliases.
Why are table aliases required in a self join?
Both copies have the same name and columns. Aliases are the only way to say which copy a column belongs to.
employees e JOIN employees m ON m.id = e.manager_id. What is m?
The row whose id equals the employee's manager_id is the manager.
An employee has NULL in manager_id. What does an inner self join do with that employee?
NULL matches nothing, so the row has no partner and an inner join drops it. A LEFT JOIN keeps it.
To find duplicate names, you join a table to itself on name. Why add a.id < b.id?
Without it every row pairs with itself, and <> would still give both (A, B) and (B, A).
How many times does the table appear in the FROM clause to show an employee, their manager and the manager's manager?
One copy per level: employee, manager and manager's manager.
Which task is a self join the natural tool for?
A row that points at another row of the same table, such as a parent or manager, is exactly what a self join resolves.
What handles a hierarchy of unknown depth?
A self join covers a fixed number of levels. WITH RECURSIVE repeats the step until the whole tree is walked.
Can a self join use LEFT JOIN?
Self join describes what is joined, not the join type. LEFT JOIN is common, to keep rows with no related row.
SELF JOIN Interview Questions
Q1. What is a self join?
A join in which a table is joined to itself. The table is listed twice with different aliases, and the ON clause relates rows of one copy to rows of the other.
Q2. When would you use a self join?
When a row refers to another row of the same table, such as employee and manager or category and parent, and when you need to compare or pair rows within a table, for example to find duplicates.
Q3. Write a query to list employees with their manager's name.
SELECT e.first_name, m.first_name AS manager FROM employees e LEFT JOIN employees m ON m.id = e.manager_id. LEFT JOIN keeps employees who have no manager.
Q4. Find employees who earn more than their manager.
SELECT e.first_name, e.last_name FROM employees e JOIN employees m ON m.id = e.manager_id WHERE e.salary > m.salary. The join puts each employee next to their manager so the salaries can be compared.
Q5. Why does a self join need aliases?
Both copies have the same table and column names. Without aliases the database cannot tell which copy a column refers to and reports an ambiguity error.
Q6. Is a self join an inner join or an outer join?
Either. "Self" only says the two tables are the same one. It can be written with INNER, LEFT, RIGHT, FULL or even CROSS JOIN.
Q7. How do you find duplicate rows with a self join?
Join the table to itself on the columns that define a duplicate and add a.id < b.id, so a row does not match itself and each pair is returned once.
Q8. What is the difference between a self join and a subquery for the same problem?
Both can relate a row to others in the same table. A self join returns columns from both rows side by side. A correlated subquery returns a single value or a yes/no per row and is often clearer for existence tests.
Q9. How do you query a hierarchy of any depth?
With a recursive CTE: an anchor query selects the root rows and a recursive member self-joins the table to the rows found so far. A plain self join only reaches a fixed number of levels.
Q10. When is a window function better than a self join?
When comparing a row with its neighbours in an order: previous or next row (LAG/LEAD), ranking, top-N per group and running totals. These scan the table once, where the self join pairs each row with many others.
Q11. How do you find the second highest salary with a self join?
Join each salary to the salaries greater than it and keep the one with exactly one greater distinct value: SELECT a.salary FROM emp a JOIN emp b ON b.salary > a.salary GROUP BY a.salary HAVING COUNT(DISTINCT b.salary) = 1.
Q12. What are the performance risks of a self join?
Row counts can grow with the square of the group size when rows are paired on a common value. Index the columns in the ON clause, pair with < instead of <>, and prefer window functions for neighbour comparisons.
Frequently Asked Questions
What is a SELF JOIN in SQL?
A self join is a regular join in which a table is joined to itself. The table appears twice in the query under two different aliases, so rows can be related to other rows of the same table.
Is there a SELF JOIN keyword?
No. You write JOIN, INNER JOIN or LEFT JOIN as usual and name the same table on both sides with different aliases.
What is a SELF JOIN used for?
Hierarchies such as employees and managers or categories and parent categories, comparing rows within a table, finding duplicates, and building pairs of rows that share a value.
How do I join employees to their managers?
Use the employees table twice: FROM employees e LEFT JOIN employees m ON m.id = e.manager_id. Select e.first_name for the employee and m.first_name for the manager.
Why do I need aliases in a self join?
Because both sides of the join are the same table. Aliases give each copy its own name so that columns can be referenced without ambiguity.
Should a self join be INNER or LEFT?
Use INNER JOIN when only rows with a related row are wanted. Use LEFT JOIN when every row must appear, for example to keep employees who have no manager.
How do I avoid duplicate pairs in a self join?
Add a condition on the primary key such as a.id < b.id. It stops a row from matching itself and returns each pair once instead of twice.
What is the difference between a self join and a recursive CTE?
A self join relates rows across a fixed number of levels, one per copy of the table. A recursive CTE repeats the self join automatically and can traverse a hierarchy of any depth.