Hook: An employee tree is just a row that keeps pointing to its parent — like following breadcrumbs from the CEO down to every report.
Question: Given a table where each employee has a manager_id, how do I return the full employee hierarchy in SQL?
Answer: Use a recursive CTE — a Common Table Expression that can reference itself. The first part picks the top employee(s), usually the CEO, and the recursive part keeps joining children to the rows already found, level by level. Add a depth column so you can see how far each person is from the root.
Interview-Ready Answer: I’d use a recursive CTE. I start with the root rows where manager_id IS NULL, then I recursively join employees to their manager to pull in direct reports until no more rows are found. I usually include a level column and an index on manager_id because that keeps the traversal fast and makes the output easy to read.
You are usually given an adjacency list model: each employee row stores its parent, which is the manager. That is simple to write but awkward to query if you want the full tree, because one row only knows its immediate boss, not all of its ancestors or descendants.
manager_id IS NULL.level (depth from the root) and sometimes path (a readable trail like CEO > VP > Manager).employees e to the rows already found on e.manager_id = current.employee_id.UNION ALL stacks each wave together. We usually prefer UNION ALL because it avoids expensive de-duplication.path, level, or both.It is the right tool when the hierarchy depth is not fixed. If you only ever need two or three levels, a few self-joins might be enough. If the depth changes over time, recursion is much cleaner and safer.
| Approach | Best for | Weakness |
|---|---|---|
| Recursive CTE | Any depth | Needs CTE support |
| Self-join chain | Fixed small depth | Gets verbose fast |
| Closure table | Very fast reads | More writes, more storage |
Closure table means a separate table that stores every ancestor-descendant pair ahead of time. It is great when reads are frequent and writes are rare, but inserts and moves become more complicated.
With an index on manager_id, each recursive step can find children quickly, so the traversal is usually close to O(n) for n employees, plus any sort cost at the end. Without that index, the engine may rescan the employee table many times, and the query can drift toward O(n^2) behavior on large data.
Real-world limits matter too: SQL Server defaults to MAXRECURSION 100 unless you override it, and MySQL 8 commonly uses cte_max_recursion_depth = 1000. Deep hierarchies are rare in normal orgs, but bad data or a very large enterprise can hit those caps.
CHECK (employee_id <> manager_id) prevents only direct self-reference, not longer cycles.Memory rule: think of the CEO as the trunk of a tree. The recursive CTE grows one branch level at a time until there are no more leaves to add.
Imagine a payroll and expense-approval service for a SaaS company. Every expense request needs the employee’s manager, and sometimes the manager’s manager, to approve it. The app uses an employee hierarchy query to build the approval chain and the org chart shown in the admin dashboard.
One team shipped a fixed three-level self-join because it seemed simpler. It worked for small departments, but a new division had seven layers. Requests from deeper employees skipped the last approver, so approvals got stuck in pending state and the finance team could not close the books.
Then a second bug appeared: a bad row made an employee point to themselves as manager. The recursive query never found a clean stopping point, so the database hit its recursion limit. In production this showed up as slow dashboard loads, timeout errors, and logs mentioning recursion depth being exhausted. Users saw missing org-chart nodes, and support saw a wave of tickets about approval chains disappearing.
The lesson is simple: recursion is not just a fancy SQL trick. It is the difference between a query that grows with the business and one that breaks the first time the org chart changes shape.
-- Demonstration of an employee hierarchy using a recursive CTE.
-- The data includes one normal tree and one orphan row so we can show
-- both the happy path and a real data-quality failure path.
CREATE TABLE employees (
employee_id INTEGER PRIMARY KEY,
employee_name VARCHAR(50) NOT NULL,
manager_id INTEGER,
CHECK (employee_id <> manager_id)
);
INSERT INTO employees (employee_id, employee_name, manager_id) VALUES
(1, 'Ava', NULL), -- CEO / root
(2, 'Ben', 1),
(3, 'Cara', 1),
(4, 'Drew', 2),
(5, 'Emma', 2),
(6, 'Finn', 3),
(7, 'Gia', 4),
(8, 'Zoe', 999); -- orphan: manager does not exist
-- 1) Full hierarchy from the root(s) downward.
-- The anchor member starts at the CEO(s).
-- The recursive member adds direct reports one level at a time.
WITH RECURSIVE org_tree AS (
SELECT
employee_id,
employee_name,
manager_id,
0 AS level,
CAST(employee_name AS VARCHAR(200)) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT
e.employee_id,
e.employee_name,
e.manager_id,
o.level + 1,
CAST(o.path || ' > ' || e.employee_name AS VARCHAR(200)) AS path
FROM employees e
JOIN org_tree o
ON e.manager_id = o.employee_id
)
SELECT
level,
employee_id,
employee_name,
manager_id,
path
FROM org_tree
ORDER BY path;
-- 2) Chain of command for one employee, walking upward to the CEO.
-- This is the same recursive idea, but the join goes the other way.
WITH RECURSIVE chain AS (
SELECT
employee_id,
employee_name,
manager_id,
0 AS hops_from_start,
CAST(employee_name AS VARCHAR(200)) AS path
FROM employees
WHERE employee_id = 7
UNION ALL
SELECT
m.employee_id,
m.employee_name,
m.manager_id,
c.hops_from_start + 1,
CAST(m.employee_name || ' <- ' || c.path AS VARCHAR(200)) AS path
FROM employees m
JOIN chain c
ON m.employee_id = c.manager_id
)
SELECT
hops_from_start,
employee_id,
employee_name,
manager_id,
path
FROM chain
ORDER BY hops_from_start;
-- 3) Data-quality check: orphan employees whose manager_id points nowhere.
-- These rows will not appear in the root-based tree, which is why bad data
-- can make a hierarchy look incomplete even when the query is correct.
SELECT
e.employee_id,
e.employee_name,
e.manager_id
FROM employees e
LEFT JOIN employees m
ON e.manager_id = m.employee_id
WHERE e.manager_id IS NOT NULL
AND m.employee_id IS NULL;Follow-up & Tricky Questions:
m.employee_id = c.manager_id. That gives you the path from the person to the root.level or depth and increment it in the recursive part. That makes indenting, sorting, and filtering much easier.employee_id <> manager_id only blocks direct self-loops, not longer cycles.manager_id, because the recursive member repeatedly looks up children by manager. In many org queries that is the single most important performance improvement.manager_id IS NULL, orphan rows and disconnected subtrees will not appear. That usually means the query is fine and the data is dirty.UNION ALL instead of UNION? UNION ALL is faster because it does not remove duplicates at every step. UNION can hide bad data and adds extra work.WHERE level < 3 in the final query stop recursion early? No — it only filters the output after the recursion has already happened. To limit work, put the depth condition inside the recursive member.Common Mistakes:
manager_id IS NULL or on the specific employee you care about.ORDER BY. Correction: sort by path or by level plus a stable sibling key.Memory Hook: Picture a flashlight shining from the CEO downward. Each recursive pass lights up the next row of the org chart until there is nothing left in the dark.
Cheat Sheet:
UNION ALL, not UNION, unless you truly need de-duplication.level and maybe path for readability.manager_id for speed.Practice Tasks:
2.path column that shows names from the CEO to each employee and order by it.