Fatskills
Practice. Master. Repeat.
Study Guide: TECH **Recursive CTEs for Hierarchical Data: A Zero-Fluff, Hands-On Guide**
Source: https://www.fatskills.com/data-science/chapter/tech-recursive-ctes-for-hierarchical-data-a-zero-fluff-hands-on-guide

TECH **Recursive CTEs for Hierarchical Data: A Zero-Fluff, Hands-On Guide**

By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.

⏱️ ~9 min read

Recursive CTEs for Hierarchical Data: A Zero-Fluff, Hands-On Guide

(For Data Analysts Who Need to Traverse Org Charts, Bill of Materials, or Any Nested Data in SQL)


1. What This Is & Why It Matters

You’re handed a table of employees where each row has an employee_id and a manager_id. Your boss asks: - "Show me the full reporting chain for Alice, all the way up to the CEO." - "How many direct and indirect reports does Bob have?" - "If we fire Carol, who loses their manager?"

Or you’re working with a Bill of Materials (BOM)—a table listing parts and their subcomponents (e.g., a car has an engine, which has pistons, which have rings). Your supply chain team needs: - "What’s the total cost of all subcomponents for Product X, 5 levels deep?" - "Which parts are used in multiple products?"

Without recursive CTEs, you’re stuck writing:
- Nested self-joins (ugly, breaks at 3+ levels).
- Application-side loops (slow, hard to debug).
- Manual Excel lookups (error-prone, not scalable).

With recursive CTEs, you:
- Traverse any depth of hierarchy in a single query.
- Answer questions in seconds that would take hours manually.
- Impress stakeholders with interactive org charts or exploded BOMs in dashboards.

Real-world impact:
- HR: Org charts, headcount rollups, succession planning.
- Manufacturing: Cost rollups, supply chain dependencies.
- E-commerce: Category trees, product bundles.
- Finance: Ownership structures, legal entity hierarchies.

If you ignore recursive CTEs, you’re either: - Writing brittle code that breaks when the hierarchy grows.
- Exporting data to Python/R for processing (slow, not real-time).
- Telling your boss, "I can’t do that in SQL."


2. Core Concepts & Components


? Recursive CTE (Common Table Expression)

  • Definition: A CTE that references itself, allowing iteration over hierarchical data.
  • Production insight: Recursive CTEs are not just for hierarchies—they’re also used for graph traversal (e.g., shortest path in a network), time-series gaps, and even generating test data.

? Anchor Member

  • Definition: The non-recursive part of the CTE (the "starting point").
  • Production insight: If your anchor is wrong, the entire query fails silently. Always test it in isolation first.

? Recursive Member

  • Definition: The part of the CTE that references itself (the "loop").
  • Production insight: Performance degrades with depth. For deep hierarchies (>10 levels), consider materializing intermediate results.

? UNION ALL

  • Definition: Combines the anchor and recursive members. UNION (without ALL) deduplicates, which can break recursion.
  • Production insight: Never use UNION instead of UNION ALL—it filters out valid rows and can cause infinite loops.

? Termination Condition

  • Definition: A WHERE clause in the recursive member that stops the recursion (e.g., WHERE manager_id IS NULL).
  • Production insight: Missing this = infinite loop = query hangs = angry DBAs.

? LEVEL or DEPTH Column

  • Definition: A counter tracking how many steps deep the recursion has gone.
  • Production insight: Essential for limiting depth (e.g., WHERE depth < 5) or aggregating by level.

? CYCLE Detection (PostgreSQL, SQL Server)

  • Definition: A clause to detect and stop infinite loops (e.g., CYCLE employee_id SET is_cycle USING path).
  • Production insight: Critical for graphs with cycles (e.g., employee A manages B, who manages A). Not supported in MySQL.

? Materialized Path vs. Adjacency List

  • Definition:
  • Adjacency List: employee_id | manager_id (what we’re using here).
  • Materialized Path: employee_id | path (e.g., /1/4/7/ for a 3-level hierarchy).
  • Production insight: Materialized paths are faster for reads but harder to maintain. Adjacency lists are simpler but require recursion.


3. Step-by-Step Hands-On: Traversing an Org Chart


Prerequisites

  • A SQL database (PostgreSQL, SQL Server, or MySQL 8.0+).
  • A table with hierarchical data (we’ll create one).

Task: Build an Org Chart Query for a Company

We’ll: 1. Create a sample employees table.
2. Write a recursive CTE to show the full reporting chain for any employee.
3. Extend it to count direct/indirect reports.


Step 1: Create the Sample Table

-- PostgreSQL/SQL Server/MySQL 8.0+
CREATE TABLE employees (
employee_id INT PRIMARY KEY,
name VARCHAR(100),
manager_id INT REFERENCES employees(employee_id),
salary DECIMAL(10, 2) ); -- Insert sample data (CEO -> VPs -> Managers -> ICs) INSERT INTO employees VALUES
(1, 'Alice (CEO)', NULL, 200000),
(2, 'Bob (VP Eng)', 1, 150000),
(3, 'Carol (VP Sales)', 1, 140000),
(4, 'Dave (Eng Manager)', 2, 120000),
(5, 'Eve (Sales Manager)', 3, 110000),
(6, 'Frank (Dev)', 4, 90000),
(7, 'Grace (Dev)', 4, 95000),
(8, 'Heidi (Sales)', 5, 85000),
(9, 'Ivan (Sales)', 5, 80000);


Step 2: Write the Recursive CTE

Goal: Show the full reporting chain for Frank (employee_id = 6).


WITH RECURSIVE org_chart AS (
-- Anchor member: Start with Frank
SELECT
employee_id,
name,
manager_id,
0 AS depth,
ARRAY[employee_id] AS path -- PostgreSQL: track path as array
FROM employees
WHERE employee_id = 6 -- Frank
UNION ALL
-- Recursive member: Join to manager
SELECT
e.employee_id,
e.name,
e.manager_id,
oc.depth + 1,
oc.path || e.employee_id -- Append to path
FROM employees e
JOIN org_chart oc ON e.employee_id = oc.manager_id ) SELECT
employee_id,
name,
manager_id,
depth,
path FROM org_chart ORDER BY depth DESC; -- Show from top (CEO) to bottom (Frank)

Expected Output:


employee_id | name            | manager_id | depth | path
------------+-----------------+------------+-------+-----------
1           | Alice (CEO)     | NULL       | 2     | {6,4,1}
4           | Dave (Eng Mgr)  | 2          | 1     | {6,4}
6           | Frank (Dev)     | 4          | 0     | {6}

What’s happening:
1. Anchor: Starts with Frank (employee_id = 6).
2. Recursive step: Joins Frank to his manager (Dave), then Dave to his manager (Bob), then Bob to his manager (Alice).
3. Termination: Stops when manager_id IS NULL (Alice has no manager).


Step 3: Count Direct and Indirect Reports

Goal: For Bob (VP Eng), count: - Direct reports (Dave).
- Indirect reports (Frank, Grace).


WITH RECURSIVE reports AS (
-- Anchor: Bob's direct reports
SELECT
employee_id,
name,
manager_id,
1 AS depth
FROM employees
WHERE manager_id = 2 -- Bob
UNION ALL
-- Recursive: Their reports
SELECT
e.employee_id,
e.name,
e.manager_id,
r.depth + 1
FROM employees e
JOIN reports r ON e.manager_id = r.employee_id ) SELECT
COUNT(*) AS total_reports,
SUM(CASE WHEN depth = 1 THEN 1 ELSE 0 END) AS direct_reports,
SUM(CASE WHEN depth > 1 THEN 1 ELSE 0 END) AS indirect_reports FROM reports;

Expected Output:


total_reports | direct_reports | indirect_reports
--------------+----------------+-----------------
3             | 1              | 2


Step 4: Handle Cycles (PostgreSQL)

Problem: What if Frank accidentally reports to Grace, who reports to Frank? Infinite loop.

Solution: Use CYCLE detection (PostgreSQL only):


WITH RECURSIVE org_chart AS (
SELECT
employee_id,
name,
manager_id,
0 AS depth,
ARRAY[employee_id] AS path
FROM employees
WHERE employee_id = 6
UNION ALL
SELECT
e.employee_id,
e.name,
e.manager_id,
oc.depth + 1,
oc.path || e.employee_id
FROM employees e
JOIN org_chart oc ON e.employee_id = oc.manager_id
WHERE NOT e.employee_id = ANY(oc.path) -- Prevent cycles ) SELECT * FROM org_chart;

For MySQL/SQL Server: Add a depth limit (e.g., WHERE depth < 10).


4. ? Production-Ready Best Practices


Performance

  • Index manager_id: Recursive CTEs do repeated self-joins. Without an index, performance tanks.
    sql CREATE INDEX idx_employees_manager_id ON employees(manager_id);
  • Limit depth: Add WHERE depth < 10 to prevent runaway queries.
  • Materialize large hierarchies: For deep hierarchies (>20 levels), pre-compute paths in a table.

Readability

  • Name CTEs clearly: WITH RECURSIVE org_chart AS (...) is better than WITH RECURSIVE cte1 AS (...).
  • Add comments: Explain the anchor/recursive members.
  • Use depth for sorting: ORDER BY depth DESC shows the hierarchy top-down.

Debugging

  • Test the anchor first: Run the anchor query alone to verify it returns the expected rows.
  • Check for cycles: If the query hangs, add CYCLE detection or a depth limit.
  • Log intermediate results: Temporarily add SELECT * FROM org_chart to see each step.

Alternatives

  • For simple hierarchies: Use CONNECT BY (Oracle) or hierarchyid (SQL Server).
  • For graphs with cycles: Use a graph database (Neo4j) or materialized paths.


5. ⚠️ Common Mistakes & Traps

Mistake Symptom Fix/Prevention
Using UNION instead of UNION ALL Missing rows or infinite loop. Always use UNION ALL in recursive CTEs.
Missing termination condition Query hangs forever. Add WHERE manager_id IS NULL or depth < N.
No index on manager_id Query runs slowly on large tables. CREATE INDEX idx_employees_manager_id ON employees(manager_id).
Recursing on the wrong column Returns incorrect hierarchy. Double-check JOIN conditions (e.g., ON e.employee_id = oc.manager_id).
Not handling cycles Infinite loop if data has cycles. Use CYCLE detection (PostgreSQL) or depth limits.


6. ? Exam/Certification Focus


Typical Question Patterns

  1. "Write a query to find all ancestors of employee X."
  2. Trick: Start with the employee (anchor) and join to manager_id (recursive).
  3. Answer: Use a recursive CTE with UNION ALL.

  4. "How would you count all direct and indirect reports for a manager?"

  5. Trick: Use depth to distinguish direct (depth = 1) vs. indirect (depth > 1).
  6. Answer: Recursive CTE with CASE WHEN depth = 1 THEN 1 ELSE 0 END.

  7. "What’s the difference between UNION and UNION ALL in a recursive CTE?"

  8. Trick: UNION deduplicates, which can break recursion.
  9. Answer: Always use UNION ALL.

  10. "How would you prevent infinite loops in a recursive CTE?"

  11. Trick: Mention CYCLE detection (PostgreSQL) or depth limits.
  12. Answer: Add WHERE depth < 10 or use CYCLE.

Key Trap Distinctions

  • Adjacency List vs. Materialized Path:
  • Adjacency list is simpler but requires recursion.
  • Materialized path is faster for reads but harder to maintain.
  • Recursive CTE vs. Nested Self-Joins:
  • Self-joins break at 3+ levels; recursive CTEs handle any depth.


7. ? Hands-On Challenge

Challenge:
Write a recursive CTE to calculate the total cost of a product’s Bill of Materials (BOM), including all subcomponents. Assume: - Table bom has columns: part_id, subcomponent_id, quantity.
- Table parts has columns: part_id, cost.

Example Data:


CREATE TABLE parts (part_id INT PRIMARY KEY, cost DECIMAL(10, 2));
INSERT INTO parts VALUES (1, 100), (2, 20), (3, 5), (4, 1);

CREATE TABLE bom (part_id INT, subcomponent_id INT, quantity INT);
INSERT INTO bom VALUES
(1, 2, 2), -- Product 1 requires 2 of Part 2
(2, 3, 4), -- Part 2 requires 4 of Part 3
(3, 4, 10); -- Part 3 requires 10 of Part 4

Goal: Calculate the total cost of Product 1, including all subcomponents.


Solution

WITH RECURSIVE bom_cost AS (
-- Anchor: Start with Product 1's direct components
SELECT
b.subcomponent_id AS part_id,
p.cost,
b.quantity,
1 AS depth
FROM bom b
JOIN parts p ON b.subcomponent_id = p.part_id
WHERE b.part_id = 1 -- Product 1
UNION ALL
-- Recursive: Get subcomponents of subcomponents
SELECT
b.subcomponent_id,
p.cost,
b.quantity * bc.quantity, -- Multiply quantities
bc.depth + 1
FROM bom b
JOIN bom_cost bc ON b.part_id = bc.part_id
JOIN parts p ON b.subcomponent_id = p.part_id ) SELECT
SUM(cost * quantity) AS total_cost FROM bom_cost;

Output:


total_cost
-----------
100 + (2 * 20) + (2 * 4 * 5) + (2 * 4 * 10 * 1) = 100 + 40 + 40 + 80 = 260

Why it works:
- The anchor gets Product 1’s direct components (Part 2).
- The recursive step multiplies quantities (e.g., 2 of Part 2 × 4 of Part 3 = 8 of Part 3).
- SUM(cost * quantity) rolls up the total cost.


8. ? Rapid-Reference Crib Sheet

Command/Concept Syntax/Example Notes
Recursive CTE WITH RECURSIVE cte_name AS (...) Must include RECURSIVE (PostgreSQL/SQL Server).
Anchor Member SELECT ... FROM table WHERE condition Start of the recursion.
Recursive Member SELECT ... FROM table JOIN cte_name ON ... References itself.
UNION ALL SELECT ... UNION ALL SELECT ... Never use UNION (deduplicates).
Termination Condition WHERE manager_id IS NULL Stops the recursion.
Depth Tracking 0 AS depth (anchor), depth + 1 (recursive) Useful for limiting depth.
Cycle Detection CYCLE part_id SET is_cycle USING path PostgreSQL only.
Index for Performance CREATE INDEX idx_manager_id ON table(manager_id) Critical for large tables.
Limit Depth WHERE depth < 10 Prevents infinite loops.
Materialized Path path VARCHAR(100) (e.g., /1/4/7/) Faster reads, harder to maintain.
Adjacency List employee_id | manager_id Simpler, requires recursion.
BOM Cost Rollup Multiply quantities in recursive step. b.quantity * bc.quantity.


9. ? Where to Go Next

  1. PostgreSQL Recursive CTE Docs – Official guide with examples.
  2. SQL Server Recursive CTEs – Microsoft’s documentation.
  3. MySQL Recursive CTEs – MySQL 8.0+ support.
  4. Book: "SQL Cookbook" by Anthony Molinaro – Chapter 10 covers hierarchies.
  5. Practice: Try LeetCode #1762. Buildings With an Ocean View (uses recursion for a different problem).


ADVERTISEMENT