Recursive CTE — Learn Window Functions, Graph Exploration, and RBAC/BOM Expansion in Practice
AdvancedWITH RECURSIVEWINDOW FUNCTIONGraph ExplorationRBAC / BOM ExpansionPostgreSQL / MySQL 8 Compatible5 questions
QUESTION 1
Level-Based Salary Ranking — Calculate Within-Depth Ranks with WITH RECURSIVE + RANK()
WITH RECURSIVEWINDOW FUNCTIONOrganization ChartRANK / PARTITION BY
Background

Once a recursive CTE assigns each node a depth, the result can be passed to a window function in the outer query. This lets you complete “ranking within the same hierarchy level” and “aggregation by hierarchy level” in a single query.

WITH RECURSIVE emp_tree AS (
  -- Anchor: root employee (depth=0)
  SELECT emp_id, manager_id, salary, 0 AS depth FROM employees
  WHERE manager_id IS NULL
  UNION ALL
  -- Recursive member: add subordinates at depth+1
  SELECT e.emp_id, e.manager_id, e.salary, et.depth + 1
  FROM employees e JOIN emp_tree et ON e.manager_id = et.emp_id
)
-- Apply the window function in the outer query (not inside the recursive member)
SELECT emp_id, depth, salary,
  RANK() OVER (PARTITION BY depth ORDER BY salary DESC) AS rank_in_level
FROM emp_tree;
Key constraint: window functions cannot be used inside the recursive member. Putting RANK() in the recursive CTE's SELECT list causes a syntax error. Always apply it in the outer query. The recursive CTE should focus on “generating the depth column,” leaving the window calculation outside.
Problem

The employees table stores employees, managers, and salaries. Retrieve each employee's organizational depth (depth) and salary ranking within the same depth (rank_in_level). Use RANK() and rank salaries from highest to lowest (DESC) within each depth. Return emp_id, emp_name, depth, salary, rank_in_level, sorted by depth ascending and then rank_in_level ascending.

Tables used
▸ employees
emp_idemp_namemanager_idsalary
1Alice (CEO)NULL1200000
2Bob1850000
3Carol1950000
4Dave2520000
5Eve2680000
6Frank3590000
7Grace3730000
Organization structure: Alice(depth=0) → Bob/Carol(depth=1) → Dave/Eve/Frank/Grace(depth=2)
Within depth=2, Grace(730000) has the highest salary → rank_in_level=1
Expected Output

Expected output (depth ascending → rank_in_level ascending):

emp_idemp_namedepthsalaryrank_in_level
1Alice (CEO)012000001
3Carol19500001
2Bob18500002
7Grace27300001
5Eve26800002
6Frank25900003
4Dave25200004

Within depth=2, salaries rank from 1 to 4. If salaries are tied, RANK() skips the next rank (for example, a tie at rank 2 is followed by rank 4).

QUESTION 2
Graph Path Exploration (Cycle Prevention with an Array) — Safely Enumerate All Routes with a Visited Array
WITH RECURSIVEGraph ExplorationArrays / ARRAYCycle Prevention
Background

Recursive CTEs apply not only to trees but also to graphs (directed graphs) that contain cycles. A cycle can cause an infinite loop, so record visited nodes in an array and prevent the recursion from visiting the same node again.

WITH RECURSIVE paths AS (
  -- Anchor: starting point and initialization of the visited array
  SELECT 'A' AS cur, 0 AS cost,
    ARRAY['A'] AS visited, 'A' AS route
  UNION ALL
  -- Recursive member: move to an unvisited node
  SELECT r.to_code, p.cost + r.cost,
    p.visited || r.to_code,            -- append to the array with ||
    p.route || '→' || r.to_code
  FROM paths p JOIN routes r ON r.from_code = p.cur
  WHERE r.to_code != ALL(p.visited)   -- prevent cycles
    AND array_length(p.visited, 1) < 4 -- maximum 3 flights
)
How the array prevents cycles: Accumulate traversed city codes in the visited column. Proceed only after confirming that the next candidate city is different from every array element with != ALL(visited). In PostgreSQL, ARRAY['TYO'] || 'OSA' appends an element to an array. MySQL 8 has no array type, so use the string 'TYO,OSA,' with FIND_IN_SET instead.
Problem

Consider a directed airline-route network. Enumerate all routes from TYO to FUK using at most 3 flights, ordered by increasing cost. Use the visited array to prevent revisiting the same city (cycles). Return route_str, total_cost.

Tables used
▸ routes (directed graph; some routes are reciprocal)
from_codeto_codecost
TYOOSA13000
TYONGO8000
NGOOSA6000
NGOFUK18000
OSAFUK15000
OSAHIR7000
HIRFUK8000
OSATYO13000
Because the reverse route OSA→TYO exists, TYO→OSA→TYO→… could loop forever. Cycle prevention with the visited array is required.
Expected Output

Expected output (total_cost ascending):

route_strtotal_cost
TYO→NGO→FUK26000
TYO→OSA→FUK28000
TYO→OSA→HIR→FUK28000
TYO→NGO→OSA→FUK29000

The reverse OSA→TYO route and routes with 4 or more flights are excluded. Ties at 28000 may appear in any order, such as route_str order.

QUESTION 3
Permission Aggregation through Role Inheritance — Implement an RBAC Permission Chain with Bottom-Up Recursion
WITH RECURSIVEBottom-UpRBACPermission Management
Background

In role-based access control (RBAC), roles can form a hierarchy in which a role inherits permissions from its parent, for example viewer → editor → admin → super_admin. Tracing every ancestor role bottom-up from a user's role and aggregating the permissions assigned to each role is a common pattern in authentication and authorization systems.

WITH RECURSIVE role_chain AS (
  -- Anchor: the target user's direct role (bottom-up starting point)
  SELECT r.role_id, r.role_name, r.parent_role_id, 0 AS chain_level
  FROM users u JOIN roles r ON r.role_id = u.role_id
  WHERE u.user_id = :uid
  UNION ALL
  -- Recursive member: follow parent_role_id upward (bottom-up)
  SELECT r.role_id, r.role_name, r.parent_role_id, rc.chain_level + 1
  FROM roles r
  JOIN role_chain rc ON r.role_id = rc.parent_role_id  -- reverse join to the parent role
)
How this differs from basic-set Q2 (bottom-up basics): The direction of ancestor traversal is the same as in basic-set Q2 (category breadcrumbs), but this question adds deduplication when multiple roles grant the same permission and recording which role granted it (an audit trail).
Problem

Retrieve all permissions held by user_id=1 (alice) by following the role-inheritance chain. List permissions from both the direct and inherited roles. Return permission_name, granted_by_role, chain_level, sorted by chain_level ascending and then permission_name ascending.

Tables used
▸ roles
role_idrole_nameparent_role_id
1super_adminNULL
2admin1
3editor2
4viewer3
▸ permissions
role_idpermission_name
1system.config
1user.delete
2user.create
2user.edit
3content.edit
3content.publish
4content.read
▸ users
user_idusernamerole_id
1alice3
2bob4
3carol2
alice's role chain: editor(3) → admin(2) → super_admin(1)
editor does not inherit viewer(4)'s permissions (inheritance moves upward only)
chain_level=0: alice's direct role (editor); chain_level=1: admin; chain_level=2: super_admin
Expected Output

Expected output (chain_level ascending → permission_name ascending):

permission_namegranted_by_rolechain_level
content.editeditor0
content.publisheditor0
user.createadmin1
user.editadmin1
system.configsuper_admin2
user.deletesuper_admin2

alice (editor) does not have the permissions of viewer (the child of the chain_level=0 role). Inheritance moves upward to parent roles only.

QUESTION 4
BOM Expansion × Cost Rollup — Calculate Total Product Cost with Recursive Expansion and Quantity Multiplication
WITH RECURSIVECumulative Quantity MultiplicationBOM / ManufacturingAggregation / GROUP BY
Background

A manufacturing bill of materials (BOM) is a tree structure of product → subassembly → component. Because quantities multiply as you move through each level, you must combine recursive expansion with quantity multiplication rather than using a simple SUM.

WITH RECURSIVE bom_tree AS (
  -- Anchor: the starting products and components
  SELECT part_id, part_id AS root_id, 1 AS accumulated_qty
  FROM parts
  UNION ALL
  -- Recursive member: expand to child components and multiply the quantity
  SELECT b.child_part_id, bt.root_id, bt.accumulated_qty * b.quantity
  FROM bom_tree bt
  JOIN bom b ON b.parent_part_id = bt.part_id
)
Cumulative quantity: At each level of a BOM, carry forward parent accumulated quantity * child required quantity. Use multiplication (*), not addition (+).

Design point: start every part at the anchor and recursively expand to child parts through the BOM. Carry quantity with accumulated_qty * child_quantity, count only leaf-part rows (unit_cost IS NOT NULL), and aggregate them at the end.

Problem

Using the product structure below, calculate total_component_cost for each part, subassembly, and finished product with a recursive CTE.

・Only leaf components (unit_cost IS NOT NULL) contribute cost; intermediate assemblies do not have their own cost
・Multiply quantities across the entire parent-to-child path (for example, the number of screws in Finished Product X = screws per Sub α × number of Sub α units)
・Return part_id, part_name, total_component_cost, sorted by part_id ascending

Tables used
▸ parts (NULL means a subassembly or finished product)
part_idpart_nameunit_cost
1Finished Product XNULL
2SubASSY-αNULL
3SubASSY-βNULL
4M3 Screw5
5M8 Bolt12
6Frame200
7Circuit Board150
▸ bom (parent → child × quantity)
parent_part_idchild_part_idquantityNotes
122Finished X → Sub α ×2
131Finished X → Sub β ×1
161Finished X → Frame ×1
246Sub α → Screw ×6
252Sub α → Bolt ×2
344Sub β → Screw ×4
371Sub β → Board ×1
Expected Output

Expected output (part_id ascending):

part_idpart_nametotal_component_cost
1Finished Product X478
2SubASSY-α54
3SubASSY-β170
4M3 Screw5
5M8 Bolt12
6Frame200
7Circuit Board150

Finished Product X = Frame(200) + Sub α×2 (12 screws=60, 4 bolts=48) + Sub β×1 (4 screws=20, board=150) = 478

QUESTION 5
Double-Recursive Department Budget Aggregation — Aggregate All Descendant Budgets with dept_tree + closure CTEs
WITH RECURSIVEClosure TableOrganization / HierarchyChained CTEs
Background

This question demonstrates the “double recursion” pattern: chain multiple recursive CTEs in one query. The first recursive CTE expands a department tree (dept_tree) with path strings and depth; the second generates a Closure Table.

WITH RECURSIVE
  -- ① First recursive CTE: expand the hierarchy tree
  tree_cte AS (
    SELECT id, parent_id, name FROM nodes WHERE parent_id IS NULL
    UNION ALL
    SELECT n.id, n.parent_id, n.name
    FROM nodes n JOIN tree_cte t ON n.parent_id = t.id
  ),
  -- ② Second recursive CTE: generate the closure table
  closure_cte AS (
    SELECT id AS ancestor, id AS descendant FROM nodes
    UNION ALL
    SELECT c.ancestor, n.id AS descendant
    FROM closure_cte c JOIN nodes n ON n.parent_id = c.descendant
  )
-- Combine the two CTEs for aggregation and other operations
SELECT ...
Double recursion (chained CTEs): PostgreSQL and MySQL 8.0+ let you declare multiple recursive CTEs after declaring WITH RECURSIVE once, separated by commas. A later CTE may reference an earlier CTE, but an earlier CTE cannot reference a later one (no forward references).

A Closure Table stores every (ancestor_id, descendant_id) pair, allowing all descendants of any department to be retrieved with an O(1) JOIN. Summing each department's own_budget through this table produces total_budget, including both direct and indirect descendants.

Problem

Using the department table below, chain two recursive CTEs and output the following:

dept_tree (recursive CTE 1): expand every department while adding depth and a path string (for example, HQ > Engineering > Backend)
closure (recursive CTE 2): build a Closure Table containing every (ancestor_id, descendant_id) pair
・In the outer query, use closure to SUM each department's descendant own_budget
・Return dept_id, dept_name, depth, path, own_budget, total_budget, sorted by depth ascending and then dept_id ascending

Tables used
▸ departments
dept_iddept_nameparent_idown_budget
1HQNULL5000
2Engineering13000
3Sales12000
4Backend21500
5Frontend21200
6Domestic Sales31800
7International Sales3900
Expected Output

Expected output (depth ascending → dept_id ascending):

dept_iddept_namedepthpathown_budgettotal_budget
1HQ0HQ500015400
2Engineering1HQ > Engineering30005700
3Sales1HQ > Sales20004700
4Backend2HQ > Engineering > Backend15001500
5Frontend2HQ > Engineering > Frontend12001200
6Domestic Sales2HQ > Sales > Domestic Sales18001800
7International Sales2HQ > Sales > International Sales900900

HQ = 5000+3000+2000+1500+1200+1800+900 = 15400 / Engineering = 3000+1500+1200 = 5700