Recursive queries and hierarchical data with CTEs

Maria Garcia Feb 2026
2 tabs
-- Create hierarchical table (org chart)
CREATE TABLE employees (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  title VARCHAR(100),
  manager_id INT REFERENCES employees(id),
  salary DECIMAL(10,2)
);

INSERT INTO employees (id, name, title, manager_id, salary) VALUES
(1, 'Alice CEO', 'CEO', NULL, 200000),
(2, 'Bob VP', 'VP Engineering', 1, 150000),
(3, 'Carol VP', 'VP Sales', 1, 150000),
(4, 'Dave Manager', 'Engineering Manager', 2, 120000),
(5, 'Eve Manager', 'Sales Manager', 3, 120000),
(6, 'Frank Dev', 'Senior Developer', 4, 100000),
(7, 'Grace Dev', 'Developer', 4, 80000),
(8, 'Henry Sales', 'Sales Rep', 5, 70000);

-- Recursive CTE: Find all subordinates of employee 2
WITH RECURSIVE subordinates AS (
  -- Base case: Start with employee 2
  SELECT id, name, title, manager_id, 1 AS level
  FROM employees
  WHERE id = 2

  UNION ALL

  -- Recursive case: Find employees managed by previous results
  SELECT e.id, e.name, e.title, e.manager_id, s.level + 1
  FROM employees e
  JOIN subordinates s ON e.manager_id = s.id
)
SELECT
  id,
  REPEAT('  ', level - 1) || name AS indented_name,
  title,
  level
FROM subordinates
ORDER BY level, name;

-- Find all managers above an employee (ancestors)
WITH RECURSIVE managers AS (
  -- Base case: Start with specific employee
  SELECT id, name, title, manager_id, 0 AS level
  FROM employees
  WHERE id = 6  -- Frank

  UNION ALL

  -- Recursive case: Find manager of previous result
  SELECT e.id, e.name, e.title, e.manager_id, m.level + 1
  FROM employees e
  JOIN managers m ON e.id = m.manager_id
)
SELECT name, title, level
FROM managers
ORDER BY level DESC;

-- Full org chart from top down
WITH RECURSIVE org_chart AS (
  -- Base case: CEO (no manager)
  SELECT
    id,
    name,
    title,
    manager_id,
    1 AS level,
    name::TEXT AS path
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- Recursive case: Employees at next level
  SELECT
    e.id,
    e.name,
    e.title,
    e.manager_id,
    oc.level + 1,
    oc.path || ' > ' || e.name
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT
  level,
  REPEAT('  ', level - 1) || name AS indented_name,
  title,
  path
FROM org_chart
ORDER BY path;

-- Aggregate: Total salary by department (including subordinates)
WITH RECURSIVE dept_salaries AS (
  SELECT
    id,
    name,
    salary,
    salary AS total_dept_salary
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  SELECT
    e.id,
    e.name,
    e.salary,
    e.salary + ds.total_dept_salary
  FROM employees e
  JOIN dept_salaries ds ON e.manager_id = ds.id
)
SELECT name, total_dept_salary
FROM dept_salaries
ORDER BY total_dept_salary DESC;

-- Category tree
CREATE TABLE categories (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  parent_id INT REFERENCES categories(id)
);

INSERT INTO categories (id, name, parent_id) VALUES
(1, 'Electronics', NULL),
(2, 'Computers', 1),
(3, 'Phones', 1),
(4, 'Laptops', 2),
(5, 'Desktops', 2),
(6, 'Smartphones', 3),
(7, 'Feature Phones', 3);

-- Find all subcategories
WITH RECURSIVE subcategories AS (
  SELECT id, name, parent_id, 1 AS level
  FROM categories
  WHERE id = 1  -- Electronics

  UNION ALL

  SELECT c.id, c.name, c.parent_id, sc.level + 1
  FROM categories c
  JOIN subcategories sc ON c.parent_id = sc.id
)
SELECT
  REPEAT('  ', level - 1) || name AS category_tree,
  level
FROM subcategories
ORDER BY level, name;

-- Find category path (breadcrumbs)
WITH RECURSIVE category_path AS (
  SELECT id, name, parent_id, name::TEXT AS path
  FROM categories
  WHERE id = 6  -- Smartphones

  UNION ALL

  SELECT c.id, c.name, c.parent_id, c.name || ' > ' || cp.path
  FROM categories c
  JOIN category_path cp ON c.id = cp.parent_id
)
SELECT path
FROM category_path
WHERE parent_id IS NULL;
2 files · sql Explain with highlit

Recursive CTEs traverse hierarchical data—org charts, category trees, graphs. I use WITH RECURSIVE for self-referential queries. Base case provides starting rows. Recursive case joins to previous iteration. Understanding termination prevents infinite loops. Level tracking shows depth in hierarchy. Path tracking prevents cycles. Hierarchical queries replace multiple application queries. Ancestors and descendants queries navigate both directions. Tree aggregations sum across hierarchies. Proper recursive queries efficiently handle arbitrary-depth structures. Essential for organizational hierarchies, bill of materials, social networks, file systems. PostgreSQL recursive CTEs are powerful for graph traversal.