SQLab Hub
Lesson 17 · SELF JOIN
Lesson 17 · Querying

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.

↺ One table, two aliases ▶ Runnable Playground examples 👥 Employee–manager 🔁 Find duplicates 🧪 7 practice exercises 📝 10-question quiz

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:

SQLite · runs in the Playground
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

Three employees and their manager_id
idfirst_nametitlemanager_id
1WilliamSenior CS ManagerNULL
11LauraSales Manager1
12BettyCOO1
✓ 3 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Employees 11 to 16 with their manager
idemployeetitlemanagermanager_title
11LauraSales ManagerWilliamSenior CS Manager
12BettyCOOWilliamSenior CS Manager
13JoshuaContent LeadMichelleCOO
14DonaldCHRODanielVP Engineering
15BarbaraSenior CS ManagerStevenParalegal
16AshleyHR ManagerMichaelSenior Counsel
✓ 6 rows · real output from the Playground database

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

SQL · syntax
SELECT a.column, b.column
FROM table_name a
JOIN table_name b
  ON a.some_column = b.other_column;
PartWhat it does
table_name aThe first copy of the table, under the alias a.
JOIN table_name bThe 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 typeAny: JOIN, LEFT JOIN and so on. "Self join" describes what is joined, not how.
The aliases are required. Without them the two copies have the same name and every column reference is ambiguous. Pick names that say what each copy represents, such as 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.

SQLite · runs in the Playground
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

Rows returned by an inner and a left self join
employeesinner_join_rowsleft_join_rows
120110120
✓ 1 row · real output from the Playground database

Of 120 employees, 110 have a manager. The other ten only appear with a LEFT JOIN, with NULL in the manager columns:

SQLite · runs in the Playground
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

Employees 8 to 13 with their manager, including those with none
idemployeetitlemanager
8StevenParalegalNULL
9MichaelSenior CounselNULL
10MichelleCOONULL
11LauraSales ManagerWilliam
12BettyCOOWilliam
13JoshuaContent LeadMichelle
✓ 6 rows · real output from the Playground database
UseWhen
JOIN (inner)You only want rows that have a related row, e.g. employees who report to someone.
LEFT JOINEvery 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.

SQLite · runs in the Playground
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

Managers and their number of direct reports
idfirst_namelast_nametitledirect_reports
9MichaelCampbellSenior Counsel14
2JohnMillerParalegal13
7BarbaraRiveraRecruiter13
10MichelleMillerCOO13
8StevenBakerParalegal12
✓ 10 rows · first 5 shown · real output from the Playground database

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:

SQL · three levels
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;
Unknown depth needs a recursive CTE. A self join handles a fixed number of levels. To walk a whole tree of any depth (an org chart, categories, a bill of materials) use 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?

SQLite · runs in the Playground
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

Employees who earn more than their manager, largest gap first
employeesalarymanagermanager_salary
Paul Robinson196032Melissa Smith51465
Joshua Hall191657Melissa Smith51465
Laura Rivera195759William Lewis61774
Joshua Nguyen182684Melissa Smith51465
John Lee203467Michael Campbell82620
✓ 5 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Employees who joined the company before their manager
employeehire_datemanagermanager_hired
Steven2015-01-14Jason2019-10-17
Michael2015-02-16Daniel2016-06-06
Sarah2015-02-26John2021-08-09
John2015-03-19Michael2021-07-28
Maria2015-04-24Barbara2020-05-14
✓ 5 rows · real output from the Playground database

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.

SQLite · runs in the Playground
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

Orders followed by another order from the same customer within 30 days
user_idfirst_orderfirst_datenext_ordernext_date
44492022-06-281522022-07-21
471322022-10-15142022-10-16
1241172023-09-18332023-09-23
✓ 3 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

How the id condition changes the number of pairs
id_conditionrows_returned
no id condition278
a.id <> b.id158
a.id < b.id79
✓ 3 rows · real output from the Playground database
ConditionRowsWhat you get
None278Every row also matches itself, so all 120 products appear at least once
a.id <> b.id158Real duplicates, but each pair twice: (A, B) and (B, A)
a.id < b.id79Each pair exactly once

So the working query uses <:

SQLite · runs in the Playground
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

Pairs of products that share a name
id_aid_bnameprice_aprice_b
611022000A Jump Starter Pack1948.86957.25
3464K Dual Dash Camera568.8973.64
3724K Dual Dash Camera568.891229.81
46724K Dual Dash Camera73.641229.81
1750A5 Hardcover Notebook 3pk164.95123.26
824Artisan Hot Sauce Trio1698.99104.33
✓ 6 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

Pairs of employees in the same department and city
employee_aemployee_bdepartmentcity
Sarah RamirezMark AndersonCustomer SuccessColumbus
William LewisRobert GreenCustomer SuccessSan Diego
Kenneth JacksonDorothy CampbellCustomer SuccessSeattle
Maria WhiteMichael WalkerDesignChicago
Jason MitchellAshley ThomasDesignDenver
Jason MitchellSandra LewisDesignDenver
✓ 6 rows · real output from the Playground database

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:

SQLite · runs in the Playground
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

The highest paid employee in each department
departmentfirst_namelast_namesalary
Customer SuccessNancyThompson203999
DesignAmyNguyen194543
EngineeringDonnaJohnson199029
FinancePaulRobinson196032
HRRonaldRamirez191037
LegalKennethCampbell192220
MarketingStevenNguyen204449
OperationsJohnLee203467
ProductStevenAdams188273
SalesLauraRivera195759
✓ 10 rows · real output from the Playground database

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?

TaskSelf joinWindow function
Previous or next rowJoin on "the row before", which is awkward to defineLAG / LEAD, one line
Top row per groupAnti-join as aboveROW_NUMBER or RANK
Running totalsJoin each row to all earlier rows: slowSUM() OVER (ORDER BY …)
Relating a row to a different row by key (manager, parent, referrer)The natural toolNot suitable
Pairs and duplicatesThe natural toolNot 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

QuestionThe table points at itself through
Each employee with their manageremployees.manager_id → employees.id
Each category with its parent categorycategories.parent_id → categories.id
Customers and who referred themusers.referred_by → users.id
Comments and the comment they reply tocomments.reply_to → comments.id
Parts and the assembly they belong toparts.parent_part_id → parts.id
Flights that connect: arrival airport = next departureA matching condition between two rows
Duplicate customers with the same emailA shared value, with a.id < b.id
Products often bought togetherorder_items joined to itself on order_id

Common SELF JOIN Mistakes

1. No aliases

❌ Error: ambiguous table and columns
SQL
SELECT first_name, first_name
FROM employees
JOIN employees
  ON id = manager_id;
✅ Two aliases
SQL
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

❌ Lists each manager's reports as "manager"
SQL
SELECT e.first_name AS employee,
       m.first_name AS manager
FROM employees e
JOIN employees m
  ON e.id = m.manager_id;
✅ Manager id = employee's manager_id
SQL
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

❌ Every product is its own duplicate
SQL
SELECT a.name
FROM products a
JOIN products b
  ON b.name = a.name;
✅ Exclude the same row
SQL
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

❌ People with no manager vanish
SQL
SELECT e.first_name, m.first_name
FROM employees e
JOIN employees m
  ON m.id = e.manager_id;
✅ LEFT JOIN keeps them
SQL
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

❌ Thousands of pairs
SQL
SELECT a.id, b.id
FROM employees a
JOIN employees b
  ON b.city = a.city
 AND a.id < b.id;
✅ Group instead
SQL
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

TipWhy it helps
Index the pointer columnmanager_id is looked up against the primary key. An index on it also speeds up "who reports to X".
Use < rather than <> for pairsIt halves the result and removes mirrored rows.
Join on selective columnsPairing rows that share a common value grows with the square of the group size.
Prefer window functions for neighbours and running totalsOne pass over the table instead of joining every row to many others.
Use a recursive CTE for deep treesOne query for any depth, instead of one join per level.

SELF JOIN Cheat Sheet

GoalPattern
Row with its parentFROM 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 parentGROUP BY p.id with COUNT(*)
Compare a row with its parentWHERE c.value > p.value
Duplicates, each pair onceON b.col = a.col AND a.id < b.id
Top row per groupLEFT JOIN t h ON h.grp = t.grp AND h.val > t.val WHERE h.id IS NULL
Three levelsAdd 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.

  1. 1Easy List the first ten employees who have a manager, with their name, their title and their manager's full name. Table: employees

    Show solution
    SQLite · runs in the Playground
    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

    e is the employee and m the manager. The inner join leaves out the ten people who have no manager.

  2. 2Easy List the employees who have no manager, using a LEFT self join. Table: employees

    Show solution
    SQLite · runs in the Playground
    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 NULL keeps those with no matching manager row. 10 employees are at the top.

  3. 3Medium For each manager, show their name, the number of direct reports and the average salary of those reports. Table: employees

    Show solution
    SQLite · runs in the Playground
    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.

  4. 4Medium How many employees earn more than their manager? Table: employees

    Show solution
    SQLite · runs in the Playground
    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.

  5. 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: employees

    Show solution
    SQLite · runs in the Playground
    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.

  6. 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: users

    Show solution
    SQLite · runs in the Playground
    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.id stops a row matching itself and keeps one row per pair. 7 pairs of customers share a last name and a city.

  7. 7Challenging Find the most expensive product in each category with a self join, not MAX or a window function. Table: products

    Show solution
    SQLite · runs in the Playground
    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

Question 1 of 10

What is a self join?

Question 2 of 10

Is SELF JOIN a keyword in SQL?

Question 3 of 10

Why are table aliases required in a self join?

Question 4 of 10

employees e JOIN employees m ON m.id = e.manager_id. What is m?

Question 5 of 10

An employee has NULL in manager_id. What does an inner self join do with that employee?

Question 6 of 10

To find duplicate names, you join a table to itself on name. Why add a.id < b.id?

Question 7 of 10

How many times does the table appear in the FROM clause to show an employee, their manager and the manager's manager?

Question 8 of 10

Which task is a self join the natural tool for?

Question 9 of 10

What handles a hierarchy of unknown depth?

Question 10 of 10

Can a self join use LEFT JOIN?

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.