| |

SQL 65 🛢️ Recursive CTEs for Hierarchical Data

Hierarchical data is data that references itself. An employee has a manager, who is also an employee. A category has a parent category, which is also a category. A part is made of components, which are themselves parts. A comment has a parent comment, which is also a comment. The relationship is recursive: the same table is joined to itself, and the join can be applied repeatedly until the hierarchy is exhausted. Querying this data with a single JOIN returns one level. Querying it with two joins returns two levels. There is no fixed number of joins that works for all hierarchies, because the depth is a property of the data, not the schema. The recursive CTE solves this by expressing the traversal as a loop, and the database iterates until no new rows are produced.

The recursive CTE was introduced in SQL:1999, and it is the standard way to query hierarchical and graph data in SQL. The construct has two parts. The anchor member is the base case: the roots of the hierarchy, the rows that have no parent. The recursive member is the step: it joins the table to the CTE, finding the children of the rows already in the result. The two are combined with UNION ALL, and the database evaluates the anchor once, then the recursive member repeatedly on the new rows, until the recursive member returns nothing. The result is the transitive closure of the relationship — every row reachable from the roots .

The recursion is not a loop in the procedural sense. It is a fixed-point computation: the CTE is defined as the union of the anchor and the recursive step, and the database iterates until the definition produces no new rows. This is the same semantics as a recursive function with a base case and a recursive case, but expressed declaratively. The key difference is that SQL’s recursion is set-based: each iteration operates on a set of rows, not a single row. The recursive member joins the entire previous result to the table, which means the traversal is breadth-first by default, and the number of iterations is the depth of the hierarchy .

This chapter covers three areas. First, why recursive CTEs exist — the problem of variable-depth hierarchies and the limitations of self-joins. Second, how recursive CTEs work — the anchor and recursive members, the UNION ALL combination, the termination condition, and the working-table evaluation. Third, the practical patterns — organizational charts, category trees, bills of materials, graph traversal, and the safeguards against cycles and unbounded recursion. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the recursion evaluation.

Key point: A recursive CTE has an anchor member (base case) and a recursive member (step), combined with UNION ALL. The database evaluates the anchor once, then the recursive member repeatedly on the new rows, until no new rows are produced. The result is the transitive closure of the relationship. Cycles cause infinite recursion unless a termination condition or the CYCLE clause is used .


Why recursive CTEs exist

The variable-depth problem. A hierarchy has a depth that is not known in advance. An organizational chart might be three levels deep in one company and ten levels deep in another. A category tree might have two levels or seven. A fixed number of self-joins works only if the maximum depth is known and the query is written for that depth. A recursive CTE works for any depth, because the recursion continues until the data is exhausted. This is the fundamental advantage: the query describes the traversal, not the number of steps .

The self-join problem. A self-join combines a table with itself, using an alias to distinguish the two roles. SELECT e.name, m.name FROM employees e JOIN employees m ON e.manager_id = m.id returns each employee with their direct manager. To get the manager’s manager, a second self-join is needed. To get three levels, a third. The number of joins is fixed in the query, and each join adds a level. For a hierarchy of unknown depth, this approach fails, because the query cannot be written without knowing the depth. The recursive CTE replaces the fixed number of joins with a loop that continues until the data is exhausted .

The transitive-closure problem. Some queries need the transitive closure of a relationship: all ancestors of a node, all descendants of a node, all paths between two nodes. The transitive closure is not expressible with a fixed number of joins, because the path length is variable. The recursive CTE computes the transitive closure directly: the anchor is the starting node, and the recursive member extends the path by one edge at each iteration. This is the standard way to compute reachability in a graph .

The working-table problem. A recursive CTE is evaluated iteratively. The database maintains a working table that holds the rows produced by the previous iteration. The anchor member is evaluated first, and its result is placed in the working table. Then the recursive member is evaluated, with the working table as the input. The rows it produces are placed in a new working table, and the process repeats. When the recursive member produces no new rows, the recursion stops. The final result is the union of all the iterations. This evaluation strategy is what makes the recursion possible without a procedural loop, and it is the same in every database that supports recursive CTEs .

The cycle problem. A hierarchy is a tree if it has no cycles. A graph can have cycles: a node that is its own ancestor, a category that is its own parent, a path that loops back. The recursive CTE does not detect cycles by itself. Without a termination condition, a cycle causes infinite recursion, and the query never returns. The CYCLE clause (SQL:1999, supported in PostgreSQL 14+ and some other databases) detects cycles and stops the recursion. Without it, the query must include a depth limit or a path check to break the cycle .

The trade-off. A recursive CTE is more complex than a simple join. It has two members, a union, and a termination condition. The performance depends on the size of the hierarchy and the number of iterations. A deep hierarchy with many rows per level can produce a large intermediate result, and the recursion can be expensive. The recursive CTE is the right tool for variable-depth hierarchies, but for a fixed-depth hierarchy, a self-join may be simpler and faster. The trade-off is between the generality of recursion and the simplicity of a fixed join.


a. The structure of a recursive CTE

A recursive CTE is defined with WITH RECURSIVE. The body has two members joined by UNION ALL (or UNION to remove duplicates). The first member is the anchor, and the second is the recursive member that references the CTE by name .

WITH RECURSIVE employee_hierarchy AS (
  -- Anchor member: the roots
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- Recursive member: the children
  SELECT e.id, e.name, e.manager_id, eh.level + 1
  FROM employees e
  JOIN employee_hierarchy eh ON e.manager_id = eh.id
)
SELECT id, name, manager_id, level
FROM employee_hierarchy
ORDER BY level, name;

The anchor member selects the roots. In an employee hierarchy, the roots are the rows where manager_id is NULL — the CEO or the top-level managers. The anchor is evaluated once, and its result is the starting set for the recursion .

The recursive member references the CTE by name. It joins the employees table to the CTE, using the join condition e.manager_id = eh.id. This finds the children of the rows already in the CTE. The level column is incremented at each step, which makes the depth of each row explicit in the result .

The UNION ALL combines the anchor and the recursive member. UNION ALL keeps all rows, including duplicates. UNION removes duplicates, which is slower but necessary if the recursive member can produce the same row more than once. In a tree hierarchy, UNION ALL is correct and faster, because each row is reachable by exactly one path. In a graph with multiple paths to the same node, UNION prevents duplicates but does not prevent cycles .

The termination condition is implicit: the recursion stops when the recursive member produces no new rows. In a tree, this happens when the leaves are reached — the rows with no children. In a graph with cycles, the recursion never stops unless a termination condition is added. The WHERE clause in the recursive member can include a depth limit: WHERE eh.level < 10. The CYCLE clause can detect cycles explicitly: CYCLE id SET is_cycle USING path .


b. The evaluation model

The recursive CTE is evaluated in iterations. Each iteration produces a new working table, and the process continues until the working table is empty .

Iteration 1: The anchor member is evaluated, and its result is placed in the working table and in the result set.

Iteration 2: The recursive member is evaluated with the working table as the input. The rows it produces are placed in a new working table and added to the result set.

Iteration 3: The recursive member is evaluated with the new working table. The rows it produces are added.

… The process repeats until the recursive member produces no new rows.

The result set is the union of all iterations. The number of iterations is the depth of the hierarchy. This is a breadth-first traversal: all the rows at level 1 are produced in iteration 1, all the rows at level 2 in iteration 2, and so on. The level column, incremented at each step, makes the depth explicit.

The working table is the key to the evaluation. It holds only the rows produced by the previous iteration, not the entire result. This means the join in the recursive member is between the previous iteration’s rows and the base table, not between the entire result and the base table. The size of the working table determines the cost of each iteration, and the total cost is the sum of the iteration costs. For a balanced tree, the cost is proportional to the number of rows. For a deep, narrow hierarchy, the cost is proportional to the depth times the width .

The result set can be large. A hierarchy with n rows produces n rows in the result, plus the columns added by the recursive member (such as level and path). The result is not deduplicated by default; if the recursive member produces the same row multiple times, all copies are kept. The UNION operator (instead of UNION ALL) removes duplicates, but it is slower and does not prevent cycles .


c. The common patterns

The recursive CTE is used for a small number of recurring patterns. Each pattern has a characteristic anchor, recursive member, and termination condition.

Organizational hierarchy. The anchor is the rows where manager_id IS NULL. The recursive member joins employees to the CTE on manager_id = id. The result is the full org chart with a level for each employee. The pattern can be extended to include a path from the root to each node: path = eh.path || '/' || e.id .

WITH RECURSIVE org AS (
  SELECT id, name, manager_id, 1 AS level, name::text AS path
  FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.id, e.name, e.manager_id, o.level + 1, o.path || ' > ' || e.name
  FROM employees e JOIN org o ON e.manager_id = o.id
)
SELECT * FROM org ORDER BY path;

Category tree. The anchor is the rows where parent_id IS NULL. The recursive member joins categories to the CTE on parent_id = id. The result is the full category tree, and it can be used to find all descendants of a category or all ancestors of a product .

WITH RECURSIVE category_tree AS (
  SELECT id, name, parent_id, 1 AS level
  FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, c.parent_id, ct.level + 1
  FROM categories c JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT * FROM category_tree ORDER BY level, name;

Bill of materials. The anchor is the top-level assembly. The recursive member joins parts to the CTE on part_id = component_id. The result is the full bill of materials, with a level for each component. The pattern is used in manufacturing to compute the total quantity of each part needed to build a product .

WITH RECURSIVE bom AS (
  SELECT part_id, component_id, quantity, 1 AS level
  FROM parts WHERE part_id = 'A'
  UNION ALL
  SELECT p.part_id, p.component_id, p.quantity, b.level + 1
  FROM parts p JOIN bom b ON p.part_id = b.component_id
)
SELECT * FROM bom ORDER BY level, part_id;

Graph traversal. The anchor is the starting node. The recursive member joins edges to the CTE on source = target. The result is all nodes reachable from the start. The depth limit is essential, because a graph can have cycles. The path column can be used to detect cycles: if the next node is already in the path, the traversal stops.

WITH RECURSIVE paths AS (
  SELECT source, target, 1 AS depth, ARRAY[source] AS visited
  FROM edges WHERE source = 'A'
  UNION ALL
  SELECT p.source, e.target, p.depth + 1, p.visited || e.target
  FROM paths p
  JOIN edges e ON p.target = e.source
  WHERE p.depth < 10 AND NOT e.target = ANY(p.visited)
)
SELECT * FROM paths;

The visited array tracks the nodes already on the path. The WHERE NOT e.target = ANY(p.visited) clause prevents the traversal from revisiting a node, which breaks cycles. This is the standard way to traverse a graph without the CYCLE clause .

Date and number series. The anchor is the starting value. The recursive member adds one unit at each step, and the WHERE clause stops at the end value. This is the standard way to generate a series without a numbers table .

WITH RECURSIVE dates AS (
  SELECT DATE '2024-01-01' AS day
  UNION ALL
  SELECT day + INTERVAL '1 day'
  FROM dates WHERE day < DATE '2024-01-31'
)
SELECT * FROM dates;

Complete Example Session

-- ============================================
-- PART 1: THE EMPLOYEE TABLE
-- ============================================

CREATE TABLE employees (
  id INT PRIMARY KEY,
  name TEXT,
  manager_id INT REFERENCES employees(id)
);

INSERT INTO employees VALUES
  (1, 'Alice', NULL),
  (2, 'Bob', 1),
  (3, 'Carol', 1),
  (4, 'Dave', 2),
  (5, 'Eve', 2),
  (6, 'Frank', 3);


-- ============================================
-- PART 2: THE BASIC HIERARCHY
-- ============================================

WITH RECURSIVE org AS (
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  SELECT e.id, e.name, e.manager_id, o.level + 1
  FROM employees e
  JOIN org o ON e.manager_id = o.id
)
SELECT id, name, manager_id, level
FROM org
ORDER BY level, name;

-- Alice (level 1)
-- Bob, Carol (level 2)
-- Dave, Eve, Frank (level 3)


-- ============================================
-- PART 3: THE PATH FROM THE ROOT
-- ============================================

WITH RECURSIVE org AS (
  SELECT id, name, manager_id, 1 AS level, name::text AS path
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  SELECT e.id, e.name, e.manager_id, o.level + 1,
         o.path || ' > ' || e.name
  FROM employees e
  JOIN org o ON e.manager_id = o.id
)
SELECT id, name, level, path
FROM org
ORDER BY path;

-- Alice
-- Alice > Bob
-- Alice > Bob > Dave
-- Alice > Bob > Eve
-- Alice > Carol
-- Alice > Carol > Frank


-- ============================================
-- PART 4: ALL SUBORDINATES OF A MANAGER
-- ============================================

WITH RECURSIVE subordinates AS (
  SELECT id, name, manager_id
  FROM employees
  WHERE id = 2  -- Bob

  UNION ALL

  SELECT e.id, e.name, e.manager_id
  FROM employees e
  JOIN subordinates s ON e.manager_id = s.id
)
SELECT * FROM subordinates;

-- Bob, Dave, Eve


-- ============================================
-- PART 5: ALL ANCESTORS OF AN EMPLOYEE
-- ============================================

WITH RECURSIVE ancestors AS (
  SELECT id, name, manager_id
  FROM employees
  WHERE id = 4  -- Dave

  UNION ALL

  SELECT e.id, e.name, e.manager_id
  FROM employees e
  JOIN ancestors a ON e.id = a.manager_id
)
SELECT * FROM ancestors;

-- Dave, Bob, Alice


-- ============================================
-- PART 6: THE CYCLE PROBLEM
-- ============================================

-- A cycle: Alice's manager is Frank
UPDATE employees SET manager_id = 6 WHERE id = 1;

-- This recursive CTE would never terminate:
-- WITH RECURSIVE org AS (
--   SELECT id, name, manager_id FROM employees WHERE manager_id IS NULL
--   UNION ALL
--   SELECT e.id, e.name, e.manager_id FROM employees e JOIN org o ON e.manager_id = o.id
-- )
-- SELECT * FROM org;

-- Fix: add a depth limit
-- WHERE o.level < 10


-- ============================================
-- PART 7: THE CYCLE CLAUSE
-- ============================================

-- PostgreSQL 14+ supports the CYCLE clause:
WITH RECURSIVE org AS (
  SELECT id, name, manager_id, 1 AS level
  FROM employees
  WHERE id = 1

  UNION ALL

  SELECT e.id, e.name, e.manager_id, o.level + 1
  FROM employees e
  JOIN org o ON e.manager_id = o.id
)
CYCLE id SET is_cycle USING path
SELECT id, name, level, is_cycle, path
FROM org;

-- The CYCLE clause detects when id repeats.
-- is_cycle is true for the row that closes the cycle.
-- path contains the sequence of ids.


-- ============================================
-- PART 8: THE DATE SERIES
-- ============================================

WITH RECURSIVE dates AS (
  SELECT DATE '2024-01-01' AS day

  UNION ALL

  SELECT day + INTERVAL '1 day'
  FROM dates
  WHERE day < DATE '2024-01-31'
)
SELECT day FROM dates;

-- Generates every day in January 2024.


-- ============================================
-- PART 9: THE BILL OF MATERIALS
-- ============================================

CREATE TABLE parts (
  part_id TEXT,
  component_id TEXT,
  quantity INT
);

INSERT INTO parts VALUES
  ('A', 'B', 2),
  ('A', 'C', 1),
  ('B', 'D', 3),
  ('C', 'D', 1);

WITH RECURSIVE bom AS (
  SELECT part_id, component_id, quantity, 1 AS level
  FROM parts
  WHERE part_id = 'A'

  UNION ALL

  SELECT p.part_id, p.component_id, p.quantity, b.level + 1
  FROM parts p
  JOIN bom b ON p.part_id = b.component_id
)
SELECT * FROM bom ORDER BY level, part_id;

-- Level 1: A → B (2), A → C (1)
-- Level 2: B → D (3), C → D (1)


-- ============================================
-- PART 10: THE COMPLETE HIERARCHY QUERY
-- ============================================

-- Reset the cycle
UPDATE employees SET manager_id = NULL WHERE id = 1;

WITH RECURSIVE org AS (
  SELECT
    id,
    name,
    manager_id,
    1 AS level,
    name::text AS path,
    ARRAY[id] AS visited
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  SELECT
    e.id,
    e.name,
    e.manager_id,
    o.level + 1,
    o.path || ' > ' || e.name,
    o.visited || e.id
  FROM employees e
  JOIN org o ON e.manager_id = o.id
  WHERE NOT e.id = ANY(o.visited)
)
SELECT
  id,
  name,
  level,
  path,
  REPEAT('  ', level - 1) || name AS indented_name
FROM org
ORDER BY path;

-- The visited array prevents cycles.
-- The indented_name column shows the hierarchy visually.
-- The path column shows the full path from the root.

The ten parts show the employee table, the basic hierarchy, the path from the root, all subordinates, all ancestors, the cycle problem, the CYCLE clause, the date series, the bill of materials, and the complete hierarchy query.


Quick Reference

Recursive CTE Structure

PartMeaning
WITH RECURSIVE name AS (...)Declares the recursive CTE
Anchor memberBase case; no self-reference
UNION ALLCombines anchor and recursive
Recursive memberReferences the CTE by name
TerminationWhen recursive member produces no new rows

Common Patterns

PatternAnchorRecursive member
Org chartWHERE manager_id IS NULLJoin on manager_id = id
Category treeWHERE parent_id IS NULLJoin on parent_id = id
SubordinatesWHERE id = ?Join on manager_id = id
AncestorsWHERE id = ?Join on id = manager_id
Bill of materialsWHERE part_id = 'A'Join on part_id = component_id
Date seriesSELECT DATE '...'day + INTERVAL '1 day'

Cycle Safeguards

SafeguardSyntaxEffect
Depth limitWHERE level < 10Stops after 10 iterations
Path arrayARRAY[id] + NOT id = ANY(path)Prevents revisiting nodes
CYCLE clauseCYCLE id SET is_cycle USING pathDetects cycles (PostgreSQL 14+)

Result Columns

ColumnPurpose
levelDepth of the row in the hierarchy
pathPath from the root to the row
visitedArray of nodes already traversed
is_cycleTrue if the row closes a cycle

Best Practices

✅ Do This:

-- Use UNION ALL for tree hierarchies
UNION ALL                                                                 -- ✅
-- Add a level column to track depth
SELECT ..., 1 AS level FROM ... WHERE manager_id IS NULL                  -- ✅
-- Add a path column for readability
o.path || ' > ' || e.name                                                 -- ✅
-- Use a depth limit or CYCLE clause for graphs
WHERE level < 10                                                          -- ✅
-- Use an array to track visited nodes
ARRAY[id] AS visited, NOT e.id = ANY(o.visited)                           -- ✅
-- Index the join column
CREATE INDEX ON employees(manager_id);                                    -- ✅

❌ Don’t Do This:

-- Don't use UNION ALL on a graph with cycles
-- Infinite recursion                                                        -- ❌
-- Don't forget the anchor member
-- The CTE has no base case                                                 -- ❌
-- Don't reference the CTE in the anchor
SELECT * FROM org WHERE ...  -- org is not defined yet                     -- ❌
-- Don't use recursion for fixed-depth hierarchies
-- A self-join is simpler                                                   -- ❌
-- Don't ignore performance on deep hierarchies
-- Add a depth limit if the full depth is not needed                        -- ❌

Common Pitfalls

PitfallWhy It HappensFix
Infinite recursionCycle in the dataDepth limit or CYCLE clause
Anchor returns no rowsWrong base case conditionCheck the WHERE clause
Missing rowsAnchor does not include all rootsCheck for multiple roots
Duplicate rowsUNION ALL with multiple pathsUse UNION or deduplicate
Slow queryDeep hierarchy, large working tableIndex the join column, limit depth
Wrong level valuesLevel not incrementedo.level + 1 in the recursive member
Path not buildingPath not concatenated`o.path
Recursive member references wrong columnJoin condition wrongCheck manager_id = id

Real-World Examples

1. Organizational Hierarchy

WITH RECURSIVE org AS (
  SELECT id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.id, e.name, e.manager_id, o.level + 1 FROM employees e JOIN org o ON e.manager_id = o.id
)
SELECT * FROM org;

2. All Subordinates

WITH RECURSIVE subs AS (
  SELECT id, name FROM employees WHERE id = 2
  UNION ALL
  SELECT e.id, e.name FROM employees e JOIN subs s ON e.manager_id = s.id
)
SELECT * FROM subs;

3. All Ancestors

WITH RECURSIVE anc AS (
  SELECT id, name, manager_id FROM employees WHERE id = 4
  UNION ALL
  SELECT e.id, e.name, e.manager_id FROM employees e JOIN anc a ON e.id = a.manager_id
)
SELECT * FROM anc;

4. Category Tree

WITH RECURSIVE tree AS (
  SELECT id, name, parent_id, 1 AS level FROM categories WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.name, c.parent_id, t.level + 1 FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree;

5. Bill of Materials

WITH RECURSIVE bom AS (
  SELECT part_id, component_id, quantity, 1 AS level FROM parts WHERE part_id = 'A'
  UNION ALL
  SELECT p.part_id, p.component_id, p.quantity, b.level + 1 FROM parts p JOIN bom b ON p.part_id = b.component_id
)
SELECT * FROM bom;

6. Date Series

WITH RECURSIVE dates AS (
  SELECT DATE '2024-01-01' AS day
  UNION ALL
  SELECT day + 1 FROM dates WHERE day < DATE '2024-01-31'
)
SELECT * FROM dates;

7. Graph Traversal

WITH RECURSIVE paths AS (
  SELECT source, target, 1 AS depth, ARRAY[source] AS visited FROM edges WHERE source = 'A'
  UNION ALL
  SELECT p.source, e.target, p.depth + 1, p.visited || e.target FROM paths p JOIN edges e ON p.target = e.source WHERE p.depth < 10 AND NOT e.target = ANY(p.visited)
)
SELECT * FROM paths;

8. Path from Root

WITH RECURSIVE org AS (
  SELECT id, name, 1 AS level, name::text AS path FROM employees WHERE manager_id IS NULL
  UNION ALL
  SELECT e.id, e.name, o.level + 1, o.path || ' > ' || e.name FROM employees e JOIN org o ON e.manager_id = o.id
)
SELECT * FROM org;

9. CYCLE Clause

WITH RECURSIVE org AS (...) CYCLE id SET is_cycle USING path SELECT * FROM org;

10. Indented Hierarchy

SELECT REPEAT('  ', level - 1) || name AS indented_name FROM org ORDER BY path;

Visual

The Recursive CTE Evaluation

┌──────────────────────────────────────────────────────────────┐
│  RECURSIVE CTE EVALUATION                                    │
│                                                              │
│  Anchor member                                               │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  SELECT ... WHERE manager_id IS NULL                 │    │
│  │  → Alice (level 1)                                   │    │
│  └──────────────────────────────────────────────────────┘    │
│    │                                                         │
│    ▼                                                         │
│  Iteration 1: recursive member on Alice                      │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  → Bob (level 2)                                     │    │
│  │  → Carol (level 2)                                   │    │
│  └──────────────────────────────────────────────────────┘    │
│    │                                                         │
│    ▼                                                         │
│  Iteration 2: recursive member on Bob, Carol                 │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  → Dave (level 3)                                    │    │
│  │  → Eve (level 3)                                     │    │
│  │  → Frank (level 3)                                   │    │
│  └──────────────────────────────────────────────────────┘    │
│    │                                                         │
│    ▼                                                         │
│  Iteration 3: recursive member on Dave, Eve, Frank           │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  → no rows                                           │    │
│  │  → recursion stops                                   │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  Result: union of all iterations                             │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Working Table

┌──────────────────────────────────────────────────────────────┐
│  THE WORKING TABLE                                           │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  Result set (accumulated)                            │    │
│  │  ┌────────────────────────────────────────────────┐  │    │
│  │  │  Alice (1)                                     │  │    │
│  │  │  Bob (2), Carol (2)                            │  │    │
│  │  │  Dave (3), Eve (3), Frank (3)                  │  │    │
│  │  └────────────────────────────────────────────────┘  │    │
│  │                                                       │    │
│  │  Working table (previous iteration only)             │    │
│  │  ┌────────────────────────────────────────────────┐  │    │
│  │  │  Iteration 1: Alice                             │  │    │
│  │  │  Iteration 2: Bob, Carol                        │  │    │
│  │  │  Iteration 3: Dave, Eve, Frank                  │  │    │
│  │  │  Iteration 4: (empty) → stop                    │  │    │
│  │  └────────────────────────────────────────────────┘  │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The recursive member joins the working table to the base    │
│  table. Only the previous iteration's rows are used.         │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Cycle Problem

┌──────────────────────────────────────────────────────────────┐
│  THE CYCLE PROBLEM                                           │
│                                                              │
│  Alice ──► Bob ──► Dave                                      │
│    ▲                  │                                      │
│    └──────────────────┘                                      │
│                                                              │
│  If Dave's manager is Alice, the hierarchy has a cycle.      │
│                                                              │
│  Without a safeguard:                                        │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  Iteration 1: Alice                                  │    │
│  │  Iteration 2: Bob                                    │    │
│  │  Iteration 3: Dave                                   │    │
│  │  Iteration 4: Alice  ← repeat                        │    │
│  │  Iteration 5: Bob    ← repeat                        │    │
│  │  ...                                                 │    │
│  │  Infinite recursion                                  │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  Safeguards:                                                 │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  Depth limit:  WHERE level < 10                      │    │
│  │  Path check:   NOT id = ANY(visited)                 │    │
│  │  CYCLE clause: CYCLE id SET is_cycle USING path      │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Path Column

┌──────────────────────────────────────────────────────────────┐
│  THE PATH COLUMN                                             │
│                                                              │
│  id  name   level  path                                      │
│  ──  ─────  ─────  ──────────────────────────────            │
│  1   Alice  1      Alice                                     │
│  2   Bob    2      Alice > Bob                               │
│  4   Dave   3      Alice > Bob > Dave                        │
│  5   Eve    3      Alice > Bob > Eve                         │
│  3   Carol  2      Alice > Carol                             │
│  6   Frank  3      Alice > Carol > Frank                     │
│                                                              │
│  The path column is built by concatenating the parent's      │
│  path with the current name:                                 │
│  o.path || ' > ' || e.name                                   │
│                                                              │
│  It shows the full lineage from the root to the row.         │
│  ORDER BY path produces a depth-first ordering.              │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Recursive CTEWITH RECURSIVE, anchor + recursive members
Anchor memberBase case; no self-reference
Recursive memberReferences the CTE by name
UNION ALLCombines anchor and recursive
TerminationRecursive member produces no new rows
EvaluationIterative; working table per iteration
level columnDepth of the row
path columnPath from the root
Cycle safeguardDepth limit, path check, or CYCLE clause
ResultTransitive closure of the relationship

Key takeaways:

  • A recursive CTE has two members. The anchor member is the base case, and the recursive member references the CTE by name. The two are combined with UNION ALL. The database evaluates the anchor once, then the recursive member repeatedly until no new rows are produced .
  • The recursion is iterative, not procedural. Each iteration produces a new working table, and the recursive member joins the previous iteration’s rows to the base table. The result is the union of all iterations. The number of iterations is the depth of the hierarchy .
  • The anchor defines the roots. For an org chart, the anchor is the rows where manager_id IS NULL. For a category tree, it is the rows where parent_id IS NULL. For a bill of materials, it is the top-level assembly. The anchor determines where the traversal starts.
  • The recursive member extends the traversal by one step. It joins the base table to the CTE on the parent-child relationship. The level column is incremented at each step, and the path column is built by concatenating the parent’s path with the current name .
  • Cycles cause infinite recursion. A graph with a cycle never terminates without a safeguard. The CYCLE clause (PostgreSQL 14+) detects cycles explicitly. A depth limit or a path array is the portable alternative .
  • The path column enables depth-first ordering. ORDER BY path produces a depth-first traversal, which is often the desired presentation for a hierarchy. The level column can be used to indent the output.
  • The working table is the key to the evaluation. The recursive member joins only the previous iteration’s rows, not the entire result. This is what makes the recursion efficient and what makes the traversal breadth-first by default .
  • Recursive CTEs are the standard tool for hierarchical data. They are used for org charts, category trees, bills of materials, graph traversal, and date series. The pattern is the same in every database that supports recursive CTEs, with minor syntax differences.

Remember: A recursive CTE is a query that references itself. The anchor member is the base case, and the recursive member is the step. The database iterates until no new rows are produced, and the result is the transitive closure of the relationship. The level column tracks depth, the path column tracks lineage, and the CYCLE clause or a depth limit prevents infinite recursion. Recursive CTEs are the standard way to query hierarchical and graph data in SQL, and they turn a variable-depth traversal into a single declarative query.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!