Question: How do you query hierarchy data in SQL, like an org chart, folder tree, or category tree?
Answer: The most common solution is a recursive CTE, which is a common table expression that can refer to itself. You start with the root rows in the anchor query, then repeatedly join each parent row to its children in the recursive part until no new rows are found. For a fixed depth, self-joins can work, but for real hierarchies, recursive CTEs are cleaner and scale to any depth.
Interview-Ready Answer: I would use a recursive CTE. I begin with the root row or rows, then recursively join the table back to the growing result set to pull each next level, usually adding a depth column and sometimes a path column for ordering. That gives me a readable query for descendants, ancestors, and breadcrumbs, and I would also mention indexing the parent key and guarding against cycles or recursion limits.
Most hierarchy tables use an adjacency list, which is a table where each row points to its parent row with something like parent_id. That is simple to write and easy to insert into, but SQL cannot know the full tree depth from a single join. A hierarchy query is the process of walking that parent-child chain until you reach the root or the leaves.
parent_id IS NULL or a specific node like a chosen manager.This is why the pattern feels like a controlled flood: one row becomes many, those many become more rows, and the process stops only when there is nothing left to add.
| Approach | Best for | Strength | Weakness |
|---|---|---|---|
| Recursive CTE | Any depth | Flexible | Needs recursion care |
| Self-join | Fixed depth | Simple | Hardcoded levels |
| Closure table | Heavy reads | Fast queries | Extra storage |
For a true tree with n reachable rows, a recursive CTE is usually O(n) in row visits if the parent key is indexed, because each row is discovered once. If parent_id is not indexed, the engine may rescan the table at each level, which can turn into something much closer to O(n^2) work on large tables. Memory usage is tied to the breadth of the tree, because the engine has to keep the current frontier of rows while it expands the next level.
parent_id is the usual win.UNION ALL unless you truly need deduplication: UNION adds duplicate removal work and can hide legitimate repeated paths in a graph-like structure.MAXRECURSION 100 unless you override it; MySQL 8 uses cte_max_recursion_depth with a default of 1000. PostgreSQL has no small fixed default cap, but bad data can still make the query run until it is stopped or until no new rows appear.Memory hook: Think of the anchor row as the front door of a building and the recursive step as walking room by room with a flashlight until you find no more doors. Once the flashlight finds nothing new, the search stops.
Imagine an e-commerce catalog service that stores categories like Electronics → Computers → Laptops. The frontend needs the full list of subcategories for the left navigation, and the breadcrumb bar needs the full path back to the root. A recursive CTE lets the service fetch all descendants of Electronics in one query and build a clean breadcrumb like Electronics > Computers > Laptops.
What goes wrong when people misunderstand hierarchy queries? A bad import can create an orphan category or a cycle. The symptom is usually a slow page, a timeout, or a sudden database error like a recursion-limit failure. Users see missing menu items, the search filter becomes inconsistent, and logs show repeated scans of the same category IDs or a warning that the maximum recursion depth was reached.
That is why hierarchy queries matter in production: they are not just about SQL syntax, they protect navigation, permissions, reporting, and anything else that depends on the tree being complete and ordered.
-- Hierarchy query demo using a recursive CTE.
-- This example uses a small employee tree and one orphan row to show
-- both the happy path and an edge case you should check in production.
WITH RECURSIVE employees(emp_id, name, manager_id) AS (
SELECT 1, 'CEO', NULL
UNION ALL SELECT 2, 'VP Engineering', 1
UNION ALL SELECT 3, 'VP Sales', 1
UNION ALL SELECT 4, 'Engineering Manager', 2
UNION ALL SELECT 5, 'Sales Lead', 3
UNION ALL SELECT 6, 'Software Engineer', 4
UNION ALL SELECT 7, 'Orphan Employee', 99
),
org AS (
-- Anchor member: start from the root rows.
SELECT
emp_id,
name,
manager_id,
0 AS depth,
CAST(name AS VARCHAR(1000)) AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive member: attach each child to the rows found so far.
-- If bad data contains a cycle, this pattern can keep expanding until
-- the database's recursion limit is reached, so production code often
-- adds a visited-node check or an explicit depth cap.
SELECT
e.emp_id,
e.name,
e.manager_id,
o.depth + 1 AS depth,
CAST(o.path || ' > ' || e.name AS VARCHAR(1000)) AS path
FROM employees e
JOIN org o
ON e.manager_id = o.emp_id
)
SELECT
emp_id,
name,
manager_id,
depth,
path
FROM org
ORDER BY path;
-- Edge case check: find rows that never connected to any root.
-- The orphan employee will appear here because manager_id = 99 does not exist.
WITH RECURSIVE employees(emp_id, name, manager_id) AS (
SELECT 1, 'CEO', NULL
UNION ALL SELECT 2, 'VP Engineering', 1
UNION ALL SELECT 3, 'VP Sales', 1
UNION ALL SELECT 4, 'Engineering Manager', 2
UNION ALL SELECT 5, 'Sales Lead', 3
UNION ALL SELECT 6, 'Software Engineer', 4
UNION ALL SELECT 7, 'Orphan Employee', 99
),
org AS (
SELECT emp_id FROM employees WHERE manager_id IS NULL
UNION
SELECT e.emp_id
FROM employees e
JOIN org o
ON e.manager_id = o.emp_id
)
SELECT e.emp_id, e.name, e.manager_id
FROM employees e
LEFT JOIN org o
ON e.emp_id = o.emp_id
WHERE o.emp_id IS NULL
ORDER BY e.emp_id;parent_id in the opposite direction. The pattern is the same; only the join direction changes.path column through the recursion, concatenating each name as you go. The final query can then order by the path or display it directly.MAXRECURSION. In production, I usually want both a data fix and a query safeguard.parent_id. A leaf is a node with zero children.ORDER BY inside the recursive member guarantee tree order? No. The only reliable order is the one you produce in the outer query, usually with a path, depth, or sort key.UNION better than UNION ALL here? Usually no. UNION ALL is faster and preserves every path, while UNION removes duplicates and can add overhead or hide important graph structure.parent_id, recursion can degrade into repeated scans. Correction: index the parent key and test with realistic data volumes.The anchor is the front door, the recursive step is one more room, and the loop ends when there are no doors left.
WITH RECURSIVE for variable-depth trees.depth and sometimes path for clarity.UNION ALL unless you truly need deduplication.