How to Use SQL Self Joins with Real Examples for Beginners

PN
StepByStepSQL
Independent SQL tutorials

A regular join combines two different tables. A self join combines a single table with itself. That one-word difference is where most beginners get stuck, because the syntax looks almost identical while the logic behind it is doing something quite different.

The confusion is understandable. When every JOIN diagram you have seen shows two separate boxes connected by a line, seeing a query that writes the same table name twice — often with two different aliases — can look like a typo rather than a deliberate technique. It is not a typo. A self join is the correct tool whenever a row in a table needs to be compared against another row in that same table, and once you see one worked through end to end, the pattern stops feeling strange.

This tutorial builds a single example from the ground up: setting up a small employees table, writing a self join against it, verifying the result, and then checking the places where a self join is the wrong choice. Everything is designed so you can paste it into a scratch database and follow along.


When a Table Needs to Refer to Itself

Before touching any syntax, it helps to name the exact situation a self join solves.

Consider an employees table where every employee row has a manager_id column. That column holds the ID of another employee in the same table — the person’s manager. So the table references itself: one row’s manager_id points at another row’s id.

If you want a result that shows each employee alongside their manager’s name, you cannot get there with a simple SELECT of the employees table alone. The manager’s name lives in the same table, just on a different row. You need the table to appear twice in the query: once playing the role of “the employee” and once playing the role of “the manager.”

That is the entire motivation for a self join. Any time a table stores a relationship between its own rows — manager to employee, category to parent category, a task with a dependency on another task — a self join is the natural way to resolve that relationship into readable output.


Setting Up a Small Example Table

Start with a tiny table so the output is easy to eyeball. The following SQL runs on PostgreSQL, MySQL, and SQLite with minor dialect differences.

CREATE TABLE employees (
    id          INTEGER PRIMARY KEY,
    name        VARCHAR(50),
    role        VARCHAR(50),
    manager_id  INTEGER
);

INSERT INTO employees (id, name, role, manager_id) VALUES
    (1, 'Ava',   'CEO',         NULL),
    (2, 'Ben',   'VP Sales',    1),
    (3, 'Cara',  'VP Eng',      1),
    (4, 'Dan',   'Sales Rep',   2),
    (5, 'Eve',   'Sales Rep',   2),
    (6, 'Finn',  'Engineer',    3);

Notice the shape of this data. Every row has an id. Every row except Ava (the CEO) has a manager_id that matches another row’s id. Ava’s manager_id is NULL, because there is no one above her in this table.

Confirm the table loaded before writing any self join, so you are not debugging two things at once:

SELECT * FROM employees ORDER BY id;

You should see six rows. If you see fewer, the insert did not run cleanly and any self join result will be misleading.


Writing the Self Join

The core pattern is: list the same table twice, give each copy a distinct alias, and then use ON to describe how the two copies relate.

SELECT
    e.name        AS employee_name,
    e.role        AS employee_role,
    m.name        AS manager_name,
    m.role        AS manager_role
FROM employees AS e
LEFT JOIN employees AS m
    ON e.manager_id = m.id
ORDER BY e.id;

Read it left to right. The first copy of the table, aliased e, stands in for the employee. The second copy, aliased m, stands in for the manager. The ON clause links them by matching the employee’s manager_id against the manager’s id.

The aliases are not cosmetic. They are required, because without them the database has no way to know which copy of the table you mean when you write name or id. Both copies would produce a column called name, and the query would raise a duplicate or ambiguous column error. The alias prefix on every column — e.name, m.name — is what disambiguates them.

Run it. The output should look something like this:

employee_nameemployee_rolemanager_namemanager_role
AvaCEONULLNULL
BenVP SalesAvaCEO
CaraVP EngAvaCEO
DanSales RepBenVP Sales
EveSales RepBenVP Sales
FinnEngineerCaraVP Eng

Ava’s manager columns are NULL because she has no manager. Every other row pairs an employee with their manager’s name and role on the same line.


LEFT JOIN vs INNER JOIN in a Self Join

The example above uses LEFT JOIN. It intentionally keeps Ava, even though she has no manager. That is what preserves every employee in the result.

Swap LEFT JOIN for INNER JOIN and the behavior changes in a way that is easy to miss:

SELECT
    e.name  AS employee_name,
    m.name  AS manager_name
FROM employees AS e
INNER JOIN employees AS m
    ON e.manager_id = m.id
ORDER BY e.id;

Now Ava disappears. An INNER JOIN requires a match on both sides, and because Ava’s manager_id is NULL, there is no manager row to match her against, so she drops out. The result contains five rows instead of six.

This mirrors the same INNER vs LEFT distinction you would see joining two different tables, and it catches people the first time they write a self join specifically to get an org chart. If the chart should include the top-level person, use LEFT JOIN. If you only want employees who report to someone, INNER JOIN is the correct and more efficient choice, since it filters out the unmatched rows at that stage rather than padding them with NULLs.


Using LEFT JOIN and an Alias to Find Rows With No Counterpart

One of the most practical self join patterns answers a question that comes up constantly: which rows have no counterpart in the same table?

Suppose you want to find employees who manage nobody — the individual contributors at the bottom of the hierarchy. Those rows exist in the table, but no other row points at them via manager_id. A self join with a LEFT JOIN, plus a WHERE check for NULL on the right side, surfaces exactly those rows:

SELECT
    e.name,
    e.role
FROM employees AS e
LEFT JOIN employees AS m
    ON m.manager_id = e.id
WHERE m.id IS NULL
ORDER BY e.id;

Walk through the logic. The first copy, e, is every employee. The second copy, m, is every employee who reports to e. The ON condition m.manager_id = e.id matches a manager to their direct reports, not the other way around — read the aliases carefully, because the direction of the match is what makes this query work.

After the LEFT JOIN, each employee is paired with each of their direct reports. Employees who manage someone get one row per report. Employees who manage nobody get exactly one row with all the m.* columns filled with NULL.

The WHERE clause then keeps only those NULL rows, which are precisely the employees with zero reports. The output here is Dan, Eve, and Finn — the three people no one reports to.

This “LEFT JOIN plus WHERE right side IS NULL” shape is a general technique, not something unique to self joins. It works joining any table to any other table, and recognizing it in a self join context is half the battle. The same pattern reveals orphaned rows, records without a parent category, and any other “row has no corresponding row in the same set” situation.


A Realistic Worked Example: Category Hierarchy

The employees table is a clean teaching example, but a category table shows the self join doing something closer to real application data. Many product catalogs store nested categories in a single table, with each category pointing at its parent.

CREATE TABLE categories (
    id         INTEGER PRIMARY KEY,
    name       VARCHAR(50),
    parent_id  INTEGER
);

INSERT INTO categories (id, name, parent_id) VALUES
    (1, 'Electronics', NULL),
    (2, 'Laptops',     1),
    (3, 'Phones',      1),
    (4, 'Accessories', 2);

To show every category alongside its parent’s name:

SELECT
    c.name        AS category,
    p.name        AS parent_category
FROM categories AS c
LEFT JOIN categories AS p
    ON c.parent_id = p.id
ORDER BY c.id;

Verify the result:

categoryparent_category
ElectronicsNULL
LaptopsElectronics
PhonesElectronics
AccessoriesLaptops

Top-level categories have no parent, so the parent_category column is NULL for them, exactly the same way Ava’s manager came back NULL earlier. If you are building a site navigation menu and need only the top-level items, filter for that NULL with the pattern shown in the previous section:

SELECT c.name
FROM categories AS c
LEFT JOIN categories AS p
    ON c.parent_id = p.id
WHERE p.id IS NULL;

That returns just Electronics, the one category with no parent.


Setting Up the Alias Names Consistently

The single biggest source of confusing self join errors is inconsistent or unclear alias naming. Because the same table appears twice, a reader (including future you) has no way to know what each copy represents without a clear alias.

A few habits that pay off:

Use aliases that describe the role of each copy in the query, not just single letters. employee and manager, or child and parent, or start_node and end_node all read far better in a code review than e and m alone — though short aliases are fine when the query is small enough that the role is obvious.

Prefix every column with its alias, even when it would technically be unambiguous without one. If a future edit adds a third copy of the table, prefixed columns keep working without a rewrite.

Avoid reusing an alias that appears in an outer query. A self join inside a subquery can shadow an alias from the surrounding query, and the database will not warn you — it will just use the innermost one, which is rarely what you intended.


When Not to Use a Self Join

A self join is the right tool for comparing rows within one table, but it is the wrong tool in several common situations, and reaching for it anyway creates slow, hard-to-read queries.

Do not use a self join when you can use a window function instead. Finding the most recent row per group, ranking rows within a group, or comparing a row to its immediately preceding row are all questions that a window function handles more efficiently and with fewer lines. A correlated subquery against the same table, or a self join against the same table with a filter, will often produce the same answer, but the window function approach typically reads more clearly and lets the query planner work with less intermediate data. If the problem is “for each row, find one related row in the same table,” a window function with PARTITION BY is usually the better answer.

Do not use a self join to fetch a simple lookup value that lives in another table. If the parent record is in a separate table, a normal join against that table is clearer and faster than duplicating the current table.

Do not reach for a self join to filter out duplicates by comparing every row to every other row. That pattern — joining a table to itself with a non-equality condition like a.id <> b.id — produces a Cartesian product sized at roughly the square of the table’s row count. On a table with 10,000 rows, that is about 100 million comparisons. Use a GROUP BY with COUNT, or DISTINCT, or a window function instead. A self join with an inequality comparison is a performance trap that works fine on toy data and falls over on anything real.

Do not chain more than two copies of the same table unless you truly need three levels of self-reference. The query becomes an unreadable tangle of aliases very quickly, and recursive CTEs exist for exactly this reason — walking arbitrary-depth hierarchies (like a full org chart to any number of levels) is a job for WITH RECURSIVE, not for a fixed number of self joins stacked together.


A Note on Recursive CTEs for Deep Hierarchies

If your hierarchy has a known, shallow depth — two or three levels at most — a self join answers the question cleanly, and you can stop here.

If the hierarchy can be arbitrarily deep, a self join does not scale. You would need to know in advance how many levels exist and write that many copies of the table. A recursive CTE, on the other hand, keeps walking up the parent chain until it runs out of parents, no matter how deep the tree goes. That is the topic for a separate tutorial, but the boundary is worth flagging: self joins for fixed-depth relationships, recursive CTEs for unknown-depth hierarchies.


Summary of the Self Join Pattern

The whole pattern fits in a few lines of description.

A self join is a regular join where the same table appears twice under two different aliases. The aliases let you refer to the two copies unambiguously. The ON clause describes how the two copies relate — which column on one copy matches which column on the other. Choose INNER JOIN when rows without a match should be dropped, and LEFT JOIN when the unmatched rows on the preserved side should be kept with NULLs on the other side. To find rows with no counterpart at all, use LEFT JOIN and filter for NULL on the right side.

For the common beginner question — how do I show a row next to its parent row in the same table? — a self join with LEFT JOIN is the answer, and the category example in this tutorial is a complete, runnable version you can adapt to any parent-child table.

What kind of self-referencing table are you working with — an employee hierarchy, a category tree, a task dependency list, or something else? Describe the columns involved and what you want each row’s output to show, and the right join shape can be worked out from there.

About the Author

StepByStepSQL is an independent, beginner-friendly resource for learning SQL, published by GT. Tutorials are compiled and explained from publicly available references rather than written from personal professional experience.