Many real-world datasets are hierarchical: org charts, category trees,
bill-of-materials. A WITH RECURSIVE CTE lets you walk these
trees in pure SQL—no application-level loops, no stored procedures.
This tutorial builds an employee org chart and queries it 7 different ways.
One table with a self-referencing foreign key: each employee points to
their manager. The CEO has manager_id = NULL.
CREATE TABLE employees (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
title TEXT NOT NULL,
manager_id INT REFERENCES employees(id),
salary INT NOT NULL
);
INSERT INTO employees (name, title, manager_id, salary) VALUES
('Alice', 'CEO', NULL, 200000),
('Bob', 'VP Engineering', 1, 160000),
('Charlie', 'VP Sales', 1, 155000),
('Diana', 'Tech Lead', 2, 130000),
('Eve', 'Senior Engineer', 2, 120000),
('Frank', 'Sales Manager', 3, 110000),
('Grace', 'Engineer', 4, 100000),
('Hank', 'Engineer', 4, 95000),
('Ivy', 'Sales Rep', 6, 80000),
('Jack', 'Sales Rep', 6, 75000);
10 employees across 4 levels. Alice manages Bob and Charlie directly; Bob manages Diana and Eve; Diana manages Grace and Hank; and so on.
A recursive CTE has two parts: the anchor (the starting rows) and the recursive term (the join that walks one level deeper). Here we start from the CEO and walk down.
WITH RECURSIVE org AS (
SELECT id, name, title,
manager_id, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.title,
e.manager_id, o.depth + 1
FROM employees e
JOIN org o ON e.manager_id = o.id
)
SELECT name, title, depth
FROM org
ORDER BY depth, name;
Result
| name | title | depth |
|---|---|---|
| Alice | CEO | 0 |
| Bob | VP Engineering | 1 |
| Charlie | VP Sales | 1 |
| Diana | Tech Lead | 2 |
| Eve | Senior Engineer | 2 |
| Frank | Sales Manager | 2 |
| Grace | Engineer | 3 |
| Hank | Engineer | 3 |
| Ivy | Sales Rep | 3 |
| Jack | Sales Rep | 3 |
The depth column starts at 0 for the CEO and increments by 1
at each level. The recursion terminates naturally when no more children
are found.
Change the anchor to start from Bob instead of the CEO. This gives you only the engineering subtree—4 employees instead of 10.
WITH RECURSIVE team AS (
SELECT id, name, title, 0 AS depth
FROM employees
WHERE name = 'Bob'
UNION ALL
SELECT e.id, e.name, e.title,
t.depth + 1
FROM employees e
JOIN team t ON e.manager_id = t.id
)
SELECT name, title, depth
FROM team
ORDER BY depth, name;
Result
| name | title | depth |
|---|---|---|
| Bob | VP Engineering | 0 |
| Diana | Tech Lead | 1 |
| Eve | Senior Engineer | 1 |
| Grace | Engineer | 2 |
| Hank | Engineer | 2 |
Reverse the direction: start from a leaf node and walk up to the CEO. This answers “who is Grace’s management chain?”
WITH RECURSIVE chain AS (
SELECT id, name, title,
manager_id, 0 AS depth
FROM employees
WHERE name = 'Grace'
UNION ALL
SELECT e.id, e.name, e.title,
e.manager_id, c.depth + 1
FROM employees e
JOIN chain c
ON e.id = c.manager_id
)
SELECT name, title, depth
FROM chain
ORDER BY depth;
Result
| name | title | depth |
|---|---|---|
| Grace | Engineer | 0 |
| Diana | Tech Lead | 1 |
| Bob | VP Engineering | 2 |
| Alice | CEO | 3 |
The join is flipped: e.id = c.manager_id walks from child to
parent instead of parent to child.
Combine the recursive CTE with an aggregate to compute the total salary cost of each manager’s entire subtree (including themselves).
WITH RECURSIVE team AS (
SELECT id, name, id AS root_id
FROM employees
WHERE id IN (1, 2, 3)
UNION ALL
SELECT e.id, e.name, t.root_id
FROM employees e
JOIN team t ON e.manager_id = t.id
)
SELECT m.name AS manager,
COUNT(*) AS team_size,
SUM(e.salary) AS total_cost
FROM team t
JOIN employees e ON t.id = e.id
JOIN employees m ON t.root_id = m.id
GROUP BY m.name
ORDER BY total_cost DESC;
Result
| manager | team_size | total_cost |
|---|---|---|
| Alice | 10 | 1125000 |
| Bob | 5 | 605000 |
| Charlie | 4 | 420000 |
Alice’s subtree is the entire company: 10 people, $1,125,000 total. Bob’s engineering org is 5 people at $605,000. Charlie’s sales team is 4 people at $420,000.
Add a WHERE clause on the recursive term to stop after 2
levels. This is useful for large trees where you only need nearby nodes.
WITH RECURSIVE org AS (
SELECT id, name, title, 0 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.title,
o.depth + 1
FROM employees e
JOIN org o ON e.manager_id = o.id
WHERE o.depth < 2
)
SELECT name, title, depth
FROM org
ORDER BY depth, name;
Result
| name | title | depth |
|---|---|---|
| Alice | CEO | 0 |
| Bob | VP Engineering | 1 |
| Charlie | VP Sales | 1 |
| Diana | Tech Lead | 2 |
| Eve | Senior Engineer | 2 |
| Frank | Sales Manager | 2 |
Only 6 rows instead of 10—the level-3 employees (Grace, Hank, Ivy,
Jack) are excluded by the WHERE o.depth < 2 guard.
Not every CTE needs RECURSIVE. A plain CTE is just a named
subquery—useful for breaking complex queries into readable steps.
WITH dept_salary AS (
SELECT manager_id,
COUNT(*) AS reports,
AVG(salary) AS avg_salary
FROM employees
WHERE manager_id IS NOT NULL
GROUP BY manager_id
)
SELECT e.name, e.title,
d.reports,
d.avg_salary
FROM employees e
JOIN dept_salary d
ON e.id = d.manager_id
ORDER BY d.reports DESC;
Result
| name | title | reports | avg_salary |
|---|---|---|---|
| Alice | CEO | 2 | 157500 |
| Bob | VP Engineering | 2 | 125000 |
| Diana | Tech Lead | 2 | 97500 |
| Frank | Sales Manager | 2 | 77500 |
| Charlie | VP Sales | 1 | 110000 |
In this tutorial you modeled a 10-person org chart and queried it with recursive and non-recursive CTEs:
WITH RECURSIVE to walk a self-referencing tree top-downUNION ALLWHERE clauseroot_id through the recursionWHERE guard on the recursive term