skip to content

Self-Joins

Joining a table to itself via aliases — the pattern behind employee–manager, find-duplicates, and consecutive-events questions. Interviewers use self-joins to test whether you understand that a join is about row sets, not physical tables.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

5

Why must a self-join give the table two aliases, as in employees e JOIN employees m?

level: juniorimportance: must knowfreq 78%

answer

  1. a join takes two row sources
  2. one table can appear twice
  3. column references need an unambiguous owner
  4. correlation name is the FROM alias
  5. e.manager_id = m.employee_id

basics

~20 s

A join combines two row sources, not two files. Aliasing the same table twice creates two independently named sources, so e.manager_id and m.employee_id refer to different rows. Without aliases every column reference is ambiguous and the statement is rejected.

solid answer

~40 s

A self-join is an ordinary join whose two inputs happen to come from the same table, so the `FROM` clause has to expose two distinct correlation names. `FROM employees AS e JOIN employees AS m ON e.manager_id = m.employee_id` reads as: treat each row as an employee (`e`), find the row whose key equals its `manager_id`, and call that row the manager (`m`). Without aliases both references are named `employees`, so `employees.name` has no single meaning and the engine rejects the query with a "not unique table/alias"-style error. The aliases are exactly what let you write `SELECT e.name AS employee, m.name AS manager` over what is physically one table. Note that this INNER form silently drops anyone whose `manager_id` is NULL — usually the CEO; `LEFT JOIN` keeps them with a NULL manager name.

code

sql · 4 lines
sql
-- employees(employee_id, name, manager_id)
SELECT e.name AS employee, m.name AS manager
FROM employees AS e
JOIN employees AS m ON e.manager_id = m.employee_id;

go deeper

for a junior

Be ready to write the employee-manager query from memory and explain what one output row means. Remember that both references need their own alias and that the alias goes in front of every column.

for a middle

Explain that a join pairs row sets rather than tables, so the same table can appear twice under two correlation names. Know why the NULL-parent row disappears from the inner form and how LEFT JOIN restores it.

for a senior

Show judgment about aliasing by role, chaining fixed levels of hierarchy, and sanity-checking row counts so an accidentally dropped top-level row is caught. Say clearly where the fixed-depth shape stops being appropriate.

for a principal

Frame when hierarchy questions should be answered by a self-join at all versus a different storage shape for the tree, and set the team convention for role-named aliases so multi-alias queries stay reviewable.

## A join operates on row sets, not on files The mental model that makes self-joins obvious is this: a join takes two *row sources* and produces the pairs of rows that satisfy its `ON` predicate. Nothing in that definition says the two sources must be different tables. If both sources are the rows of `employees`, the join still works exactly the same way — it pairs an `employees` row with another `employees` row. That is all a self-join is. There is no `SELF JOIN` keyword, no special syntax, and no copy of the table made anywhere. You write `JOIN` and name the same table twice. ## Why the alias is mandatory Every table reference in a `FROM` clause is exposed under a name, and those names must be distinct within the clause. Write this and it fails: ```sql -- rejected: two references both called employees SELECT name, manager_id FROM employees JOIN employees ON employees.manager_id = employees.employee_id; ``` `employees.manager_id` cannot be resolved: two things in scope answer to `employees`. Engines reject this outright rather than guessing. Supplying a *correlation name* (the standard's term for a table alias) fixes it: ```sql SELECT e.name AS employee, m.name AS manager FROM employees AS e JOIN employees AS m ON e.manager_id = m.employee_id; ``` Now `e` and `m` are two separate handles onto the same set of rows. `e.name` and `m.name` are different columns of the *result*, even though they are the same column of the *table*. A portability note: the `AS` keyword before a table alias is optional in the standard and rejected by some engines, so `FROM employees e` is the spelling that works everywhere. ## Reading a self-join out loud A good habit is to name the aliases for the *role* each row plays, never `t1`/`t2`. In the query above, `e` is "the employee" and `m` is "the manager". The predicate `e.manager_id = m.employee_id` then reads as prose: this employee's manager id equals that person's own id. Roles make three-alias queries survivable too: ```sql SELECT e.name AS employee, m.name AS manager, g.name AS grand_manager FROM employees AS e LEFT JOIN employees AS m ON e.manager_id = m.employee_id LEFT JOIN employees AS g ON m.manager_id = g.employee_id; ``` Each extra level of hierarchy is one more alias, which is why this shape only works when the depth is known and fixed. ## What the INNER version quietly drops The classic follow-up trap: the top of the hierarchy has no manager, so its `manager_id` is NULL. NULL never equals anything, so the `ON` predicate is never true for that row and an INNER self-join returns everyone *except* the CEO. If the org chart is supposed to be complete, the query is wrong and nothing complains. `LEFT JOIN` preserves the left side and NULL-extends the manager columns: ```sql SELECT e.name AS employee, m.name AS manager -- manager is NULL for the CEO FROM employees AS e LEFT JOIN employees AS m ON e.manager_id = m.employee_id; ``` A row count sanity check catches this instantly: the result of the LEFT form should have exactly as many rows as `employees` when `manager_id` points at a unique key. ## The direction of the join is a choice Swapping the roles asks a different question. `FROM employees AS m JOIN employees AS e ON e.manager_id = m.employee_id` with `SELECT m.name, COUNT(*)` and a `GROUP BY` lists managers with their headcount; the same physical pairing, read from the other end. Deciding which alias is the "driving" side is the whole design step of a self-join. ## Where self-joins stop A self-join reaches a *fixed* number of hops. One join is one level up; three joins are three levels up. If you need "every ancestor, however deep", the number of joins is data-dependent and the shape no longer works — that is what recursive queries exist for. Similarly, if the relationship is many-to-many (an employee with several mentors), the self-join is still correct but each employee produces one output row per match, which is normal join behaviour rather than a bug. ## Things interviewers listen for That you say "two row sources" rather than "two copies of the table"; that you alias by role; that you notice the NULL parent disappears from the inner form; and that you can state what a single row of the result means without hesitating.

  • What happens to the CEO's row in the INNER version, and why?
    The CEO's `manager_id` is NULL, and NULL is never equal to anything, so `e.manager_id = m.employee_id` is never true for that row. An INNER self-join therefore returns every employee except the CEO, silently. `LEFT JOIN employees AS m` keeps the row and fills the manager columns with NULLs, which is usually what the report wanted.
  • How would you also show each employee's grand-manager in the same query?
    Add a third alias and chain: `LEFT JOIN employees g ON m.manager_id = g.employee_id`. Use LEFT at every level so employees who report to the CEO do not vanish. The important limitation is that the number of joins fixes the depth in the query text — this shape answers "two levels up", not "all ancestors".
  • Do the two aliases have to select the same columns or the same rows?
    No. Each alias is an independent reference to the table's rows; you may project different columns from each, and predicates in `ON` or `WHERE` may restrict them differently — for example `JOIN employees m ON e.manager_id = m.employee_id AND m.active = true` treats only active people as valid managers while leaving `e` unfiltered.

It is the same list of people read twice with two different name tags: the first pass wears the badge "employee", the second wears "manager". Same list, two roles, so every reference says which badge it means.

saying these in an interview costs you the question

  • Claims SQL cannot join a table to itself
  • Thinks the alias makes a physical copy of the table
  • Writes employees.manager_id unqualified and expects it to resolve
  • Misses that an inner self-join drops the NULL-parent row
  • Believes a self-join requires a temp table or subquery

context

open as a page

With a self-join, how do you find every row in users that shares an email with another row?

level: middleimportance: must knowfreq 65%

basics

~20 s

Join users to itself on equal email and unequal key: ON a.email = b.email AND a.user_id <> b.user_id. Every row that has a twin is returned, once per twin, so use DISTINCT when three or more rows can share an address.

open as a page

Why does pairing a table with itself using a.id <> b.id return every pair twice?

level: middleimportance: should knowfreq 48%

basics

~20 s

The predicate a.id <> b.id keeps both orderings of every pair, so (1,2) and (2,1) both survive. Replacing it with a.id < b.id keeps exactly one ordering, cutting n(n-1) rows to n(n-1)/2 while still excluding self-pairs.

open as a page

What does COUNT(DISTINCT h.salary) + 1 compute in a self-join with ON h.salary > e.salary?

level: middleimportance: should knowfreq 52%

basics

~20 s

It computes each employee's salary rank: the number of distinct salaries above theirs, plus one for their own. Tied salaries share a rank and the numbering has no gaps, matching dense-rank behaviour, provided the join is a LEFT JOIN so the top earner survives.

open as a page

Using a self-join, how do you pair each sensor reading with the previous reading for that device?

level: seniorimportance: should knowfreq 42%

basics

~20 s

A join has no notion of "the row before", so the ON clause must define it: join the table to itself on the same device with an earlier timestamp, then keep only the nearest earlier row — via a MAX subquery or a NOT EXISTS check that no reading falls in between.

open as a page