Recursive CTE — Learn Organization Charts, Category Hierarchies, and Tree Structures from the Basics
BasicWITH RECURSIVEHierarchical DataOrganization Charts / TreesPostgreSQL / MySQL 8 Compatible5 questions
QUESTION 1
Expand an Organization Hierarchy — Retrieve a Department Tree with Depth
WITH RECURSIVEAnchor / Recursive MemberOrganization Chartdepth / path
Background

WITH RECURSIVE (a recursive CTE) repeatedly expands self-referencing hierarchical data. It is a SQL-standard syntax essential for data with parent-child relationships, including organization charts, category hierarchies, comment trees, and folder structures.

A recursive CTE connects two parts with UNION ALL: an anchor member (which retrieves the initial rows) and a recursive member (which joins the previous result to the source table to generate the next rows).

WITH RECURSIVE cte AS (
  -- ① Anchor member: retrieve the starting rows (root nodes)
  SELECT id, name, parent_id, 0 AS depth
  FROM tree WHERE parent_id IS NULL

  UNION ALL

  -- ② Recursive member: join the previous CTE result to the source table and add child nodes
  SELECT t.id, t.name, t.parent_id, cte.depth + 1
  FROM tree t
  JOIN  cte ON t.parent_id = cte.id  -- connect children to the previous result's id
)
SELECT * FROM cte;
How iteration works: Use the anchor result as the working table. The recursive member joins that working table to the source table to generate new rows. The new rows become the next working table, and the recursive member runs again. Repeat until zero rows are added.
Problem

The departments table represents the company's department tree. Retrieve every department with its depth (depth) and a path string from the root (path). Return dept_id, dept_name, parent_id, depth, path; path is the department names concatenated from the root with / separators. Sort by dept_id ascending.

Tables used
▸ departments
dept_iddept_nameparent_id
1CompanyNULL
2Engineering1
3Sales1
4Backend2
5Frontend2
6Domestic Sales3
7International Sales3
Tree structure:
Company(1)
├ Engineering(2)
│ ├ Backend(4)
│ └ Frontend(5)
└ Sales(3)
  ├ Domestic Sales(6)
  └ International Sales(7)
Expected Output

Expected output (dept_id ascending):

dept_iddept_nameparent_iddepthpath
1CompanyNULL0Company
2Engineering11Company/Engineering
3Sales11Company/Sales
4Backend22Company/Engineering/Backend
5Frontend22Company/Engineering/Frontend
6Domestic Sales32Company/Sales/Domestic Sales
7International Sales32Company/Sales/International Sales
Model Answer
WITH RECURSIVE dept_tree AS (

  -- ① Anchor member: parent_id IS NULL → retrieve only the root node (Company)
  SELECT
    dept_id,
    dept_name,
    parent_id,
    0                AS depth,  -- root depth = 0
    dept_name        AS path    -- the root itself is the path starting point
  FROM  departments
  WHERE parent_id IS NULL

  UNION ALL

  -- ② Recursive member: join child departments to each previous dept_tree row and move down one level
  SELECT
    d.dept_id,
    d.dept_name,
    d.parent_id,
    dt.depth + 1,              -- add 1 to the parent's depth
    dt.path || '/' || d.dept_name  -- concatenate the child department name to the parent's path
  FROM  departments d
  JOIN  dept_tree dt ON d.parent_id = dt.dept_id  -- child parent_id = parent dept_id

)
SELECT
  dept_id,
  dept_name,
  parent_id,
  depth,
  path
FROM  dept_tree
ORDER BY dept_id;

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iteration 1
  3. Recursion iteration 2
  4. Recursion iteration 3
  5. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE dept_tree AS ( SELECT dept_id, dept_name, parent_id, 0 AS depth, dept_name AS path FROM departments WHERE parent_id IS NULL UNION ALL SELECT d.dept_id, d.dept_name, d.parent_id, dt.depth + 1, dt.path || '/' || d.dept_name FROM departments d JOIN dept_tree dt ON d.parent_id = dt.dept_id ) SELECT dept_id, dept_name, parent_id, depth, path FROM dept_tree ORDER BY dept_id;
LEGEND
Rows read / loaded
Excluded / hidden data
① Anchor Member (Initialization)
WHERE parent_id IS NULL → Retrieve the Root NodeThe anchor member retrieves only the root node (Company) with WHERE parent_id IS NULL. It initializes depth=0 and path='Company'. This one row becomes the first working table.
1 / 7
dept_iddept_nameparent_iddepthpath
1CompanyNULL0Company
Anchor: 1 row (initial working table)
Recursive CTE Iteration (Anchor → Recursion → Termination)
ANCHOR Anchor member — retrieve the root node (depth=0)
Working Table (Initial)
dept_iddepthpath
1 Company0Company
Working Table for the Next Recursion
dept_iddepth
1 Company0
ITER 1 Recursion 1 — retrieve children of parent_id=1 (depth=1)
Cumulative Result
dept_iddepthpath
1 Company0Company
2 Engineering1Company/Engineering
3 Sales1Company/Sales
Working Table for the Next Recursion
dept_iddepth
2 Engineering1
3 Sales1
ITER 2 Recursion 2 — retrieve children of parent_id IN(2,3) (depth=2)
Cumulative Result
dept_iddepthpath
1 Company0
2 Engineering1
3 Sales1
4 Backend2Company/Engineering/…
5 Frontend2Company/Engineering/…
6 Domestic Sales2Company/Sales/…
7 International Sales2Company/Sales/…
Next Recursion (0 Rows Added → Terminate)
Reason
No children exist for dept_id 4–7
Added rows = 0 → loop ends
LEARNING POINTS
UNION ALL vs UNION: Recursive CTEs should use UNION ALL. UNION performs duplicate elimination at every recursion, which adds cost and may remove an intended hierarchy. When the same id cannot be reached through multiple paths, UNION ALL is the correct choice.
Path concatenation helps debugging: The path column represents the tree as a string, so the result shows which root each row came from. It can also be used directly to generate application breadcrumbs.
The same syntax works in MySQL 8.0 and SQLite: WITH RECURSIVE is part of SQL:1999 and is shared by PostgreSQL, MySQL 8.0+, SQLite 3.35+, and SQL Server. Oracle has its own CONNECT BY syntax, but WITH RECURSIVE is also available from Oracle 11gR2 onward.
ANTI-PATTERNS
No termination condition causes an infinite loop: If the data contains a cycle such as A→B→A, recursion never ends and the database may fail on memory or time limits. PostgreSQL can detect cycles with CYCLE dept_id SET is_cycle USING path (PG14+); a guard such as depth < 10 is also commonly added to WHERE.
Referencing the anchor CTE twice in the recursive member: The same CTE name cannot be referenced more than once in the recursive member's FROM/JOIN, for example FROM dept_tree a JOIN dept_tree b. A recursive member may self-reference the CTE only once.
Field Notes: Adjacency List vs. Nested Set Models
This parent_id design is called the Adjacency List model; it is simple and easy to update. Before WITH RECURSIVE became widely available, the Nested Set model with lft/rgt columns was common for efficient hierarchy queries, but updates were expensive. Today, an adjacency list is sufficient in most cases because of WITH RECURSIVE. For very large hierarchies with millions of rows, consider a dedicated type such as PostgreSQL's ltree.
QUESTION 2
Retrieve All Ancestors of a Node — Build Category Breadcrumbs with Bottom-Up Recursion
WITH RECURSIVEBottom-UpCategory HierarchyBreadcrumb
Background

A recursive CTE can expand not only from leaf to root (bottom-up), the reverse of root-to-leaf (top-down). Retrieving the current location and its ancestor categories as an e-commerce breadcrumb is a typical use.

WITH RECURSIVE ancestors AS (
  -- ① Anchor member: start from the target node (leaf)
  SELECT category_id, category_name, parent_id, 0 AS level
  FROM  categories
  WHERE category_id = :target_id  -- specify the starting leaf node

  UNION ALL

  -- ② Recursive member: follow the current parent_id to retrieve the parent (upward)
  SELECT c.category_id, c.category_name, c.parent_id, a.level + 1
  FROM  categories c
  JOIN  ancestors a ON c.category_id = a.parent_id  -- ← the JOIN direction is reversed
)
SELECT * FROM ancestors ORDER BY level DESC;
The difference from top-down is the JOIN direction:Top-down uses d.parent_id = dt.dept_id (child parent = previous row id). Bottom-up uses c.category_id = a.parent_id (source-table id = previous row parent id). Swapping the two sides of the JOIN reverses the direction.
Problem

An e-commerce site has a category tree. Retrieve all ancestors of category_id=6 (Men's Sneakers) and return category_id, category_name, parent_id, level with the root at the top. Sort by level DESC so the root comes first.

Tables used
▸ categories
category_idcategory_nameparent_id
1ALLNULL
2Fashion1
3Electronics1
4Men2
5Women2
6Men's Sneakers4
7Men's Jacket4
Target node: category_id=6 (Men's Sneakers)
Expected breadcrumb: ALL → Fashion → Men → Men's Sneakers
Expected Output

Expected output (level DESC = root first):

category_idcategory_nameparent_idlevel
1ALLNULL3
2Fashion12
4Men21
6Men's Sneakers40

level=0 is the target node itself; larger levels are higher in the hierarchy, toward the root. level DESC puts the root first.

Model Answer
WITH RECURSIVE ancestors AS (

  -- ① Anchor member: start from the target node (Men's Sneakers)
  SELECT
    category_id,
    category_name,
    parent_id,
    0 AS level         -- starting node has level = 0
  FROM  categories
  WHERE category_id = 6  -- specify Men's Sneakers as the starting point

  UNION ALL

  -- ② Recursive member: use the current parent_id to retrieve the row whose category_id is the parent
  SELECT
    c.category_id,
    c.category_name,
    c.parent_id,
    a.level + 1          -- move upward one level at a time
  FROM  categories c
  JOIN  ancestors a ON c.category_id = a.parent_id  -- ← reverse JOIN direction for bottom-up traversal

)
SELECT
  category_id,
  category_name,
  parent_id,
  level
FROM  ancestors
ORDER BY level DESC;  -- sort by descending level so the root comes first

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iteration 1
  3. Recursion iteration 2
  4. Recursion iteration 3
  5. Recursion iteration 4
  6. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE ancestors AS ( SELECT category_id, category_name, parent_id, 0 AS level FROM categories WHERE category_id = 6 UNION ALL SELECT c.category_id, c.category_name, c.parent_id, a.level + 1 FROM categories c JOIN ancestors a ON c.category_id = a.parent_id ) SELECT category_id, category_name, parent_id, level FROM ancestors ORDER BY level DESC;
LEGEND
Rows read / loaded
① Anchor Member (Starting Point: Men's Sneakers)
WHERE category_id=6 → Retrieve the Starting NodeRetrieve category_id=6 (Men's Sneakers) as the starting point and set level=0. Follow parent_id upward from this node to expand its ancestors.
1 / 10
category_idcategory_nameparent_idlevel
6Men's Sneakers40
Anchor: 1 row (starting node)
Bottom-Up Iteration (Leaf → Root)
ANCHOR Starting point: category_id=6 (Men's Sneakers, level=0)
JOIN Direction (Bottom-Up)
DirectionJOIN Condition
Top-downchild.parent_id = cte.id
Bottom-up ★tbl.id = cte.parent_id
Bottom-Up Iteration Flow
IterationRetrieved Nodelevel
anchorMen's Sneakers(6)0
iter1Men(4)1
iter2Fashion(2)2
iter3ALL(1)3
iter4(0 rows added → terminate)
LEARNING POINTS
Swapping the JOIN direction reverses traversal: Top-down uses child.parent_id = cte.id; bottom-up uses parent.id = cte.parent_id. The SQL structure is nearly identical: only the JOIN direction changes. The anchor WHERE clause determines where traversal starts.
level DESC is the natural breadcrumb order: Bottom-up recursion increases level, so the starting node is 0 and the root has the largest value. ORDER BY level DESC puts the root first, producing a natural breadcrumb order (home > category > ... > current location).
Assemble breadcrumbs in the application: Rows sorted by level DESC can be used directly as a breadcrumb array. SQL can also combine them into one string with STRING_AGG(category_name, ' > ' ORDER BY level DESC).
ANTI-PATTERNS
Using the wrong anchor WHERE clause: Bottom-up recursion starts from a leaf or intermediate node. If you start from the root, the query becomes top-down. Decide what node to trace upward from before writing the anchor.
Omitting ORDER BY leaves row order undefined: Recursive results are returned in an order affected by the DBMS implementation and execution plan. Always add ORDER BY level DESC when the breadcrumb display order must be guaranteed.
Field Notes: Three Practical Hierarchy-Query Patterns
WITH RECURSIVE is commonly used in three ways: ① retrieve all descendants (top-down), such as all product IDs below a category for an IN filter; ② retrieve all ancestors (bottom-up), as in this breadcrumb example; and ③ compute a lowest common ancestor (LCA) by finding two ancestor sets, intersecting them, and choosing the deepest match. All three use WITH RECURSIVE with a different JOIN direction.
QUESTION 3
Expand Comment Threads with a Depth Limit — Safe Recursion with MAXRECURSION / Depth Guards
WITH RECURSIVEDepth LimitComment TreeInfinite-Loop Prevention
Background

Comments on social networks and forums have a nested thread structure of replies to comments and replies to replies. A recursive CTE can expand the whole thread, but invalid cycles or abnormal depth can cause an infinite loop. Storing depth in a column and setting an upper bound in WHERE is a standard production guard.

WITH RECURSIVE thread AS (
  SELECT comment_id, content, parent_id, 0 AS depth
  FROM  comments WHERE parent_id IS NULL

  UNION ALL

  SELECT c.comment_id, c.content, c.parent_id, t.depth + 1
  FROM  comments c
  JOIN  thread t ON c.parent_id = t.comment_id
  WHERE t.depth + 1 < 3   -- ← recurse only from rows where depth is less than 3
)
Pay attention to the WHERE position:Write the depth limit as WHERE t.depth + 1 < N after the JOIN in the recursive member. Putting it in the outer query's WHERE does not stop recursion and cannot prevent an infinite loop.
Problem

The comments table stores comments on an article and their replies. Expand every comment into a thread, but do not retrieve depth 3 or greater (depth >= 3). Return comment_id, content, parent_id, depth, indent, where indent is an indentation string of depth × 2 spaces. Sort by comment_id ascending.

Tables used
▸ comments
comment_idcontentparent_id
1Interesting articleNULL
2I agreeNULL
3Please tell me more1
4Here is a reference link1
5Thanks!3
6I think so too3
7A deeper reply5
comment_id=7 (depth=3) is excluded by the depth limit.
Expected Output

Expected output (comment_id ascending):

comment_idcontentparent_iddepthindent
1Interesting articleNULL0(empty string)
2I agreeNULL0(empty string)
3Please tell me more11  (2 spaces)
4Here is a reference link11  (2 spaces)
5Thanks!32    (4 spaces)
6I think so too32    (4 spaces)

comment_id=7 (depth=3) is not retrieved. Only 6 rows are returned.

Model Answer
WITH RECURSIVE thread AS (

  -- ① Anchor member: retrieve root comments (not replies)
  SELECT
    comment_id,
    content,
    parent_id,
    0                AS depth,
    ''               AS indent  -- root indentation is an empty string
  FROM  comments
  WHERE parent_id IS NULL

  UNION ALL

  -- ② Recursive member: retrieve child comments, but recurse only from rows where depth < 3
  SELECT
    c.comment_id,
    c.content,
    c.parent_id,
    t.depth + 1,
    REPEAT('  ', t.depth + 1)  -- generate two spaces for each depth level
  FROM  comments c
  JOIN  thread t ON c.parent_id = t.comment_id
  WHERE t.depth + 1 < 3          -- block recursion to depth 3 and beyond (this is the guard)

)
SELECT
  comment_id,
  content,
  parent_id,
  depth,
  indent
FROM  thread
ORDER BY comment_id;

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iteration 1
  3. Recursion iteration 2
  4. Recursion iteration 3
  5. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE thread AS ( SELECT comment_id, content, parent_id, 0 AS depth, '' AS indent FROM comments WHERE parent_id IS NULL UNION ALL SELECT c.comment_id, c.content, c.parent_id, t.depth + 1, REPEAT(' ', t.depth + 1) FROM comments c JOIN thread t ON c.parent_id = t.comment_id WHERE t.depth + 1 < 3 ) SELECT comment_id, content, parent_id, depth, indent FROM thread ORDER BY comment_id;
LEGEND
Rows read / loaded
Excluded / hidden data
① Anchor Member (Root Comments at depth=0)
WHERE parent_id IS NULL → Retrieve comment_id=1,2Retrieve root comments with no parent (comment_id=1 and 2). Set depth=0 and indent='' (the empty string) as the initial values.
1 / 8
comment_idcontentparent_iddepthindent
1Interesting articleNULL0''
2I agreeNULL0''
Anchor: 2 rows
How the Depth Guard Works
WHERE Placement Matters Put the condition at the end of the recursive member to stop going deeper
✓ Correct Placement (inside the recursive member)
Code LocationEffect
JOIN ... WHERE t.depth+1 < 3Blocks recursion itself
vs
× Incorrect Placement (outer query)
Code LocationEffect
Outer WHERE depth < 3Does not stop recursion; only filters the result
(risk of an infinite loop)
LEARNING POINTS
Write the depth guard in the recursive member's WHERE: Filtering in the outer query does not stop recursion. Put WHERE t.depth + 1 < N after the recursive JOIN to block expansion at or beyond the limit. The depth limit belongs inside the recursive member.
Generate indentation with REPEAT(str, n): REPEAT(' ', depth) is available in both PostgreSQL and MySQL and repeats a string n times. It can render a tree directly in a SQL result. If the application renders indentation, pass only depth and let the UI handle it.
PostgreSQL's CYCLE clause (PG14+): Add CYCLE comment_id SET is_cycle USING path to detect already visited nodes and prevent infinite loops in cyclic graphs. Combining it with a depth limit provides two layers of protection.
ANTI-PATTERNS
Deploying to production without a depth limit: Even if development data is shallow, users can create very deep threads. Expansion can reach thousands or tens of thousands of rows and degrade performance. Every recursive CTE should have a depth or row-count limit.
Relying only on MySQL 8.0's recursion_depth setting: MySQL's cte_max_recursion_depth (default 1000) is an error-protection limit, not an intentional application limit. The business rule for maximum depth belongs in the SQL.
Field Notes: Production Design for Thread Expansion
For comment threads, a common production design retrieves only the first N items and lazy-loads the rest behind a “view more replies” control. At the SQL level, combine a depth-limited WITH RECURSIVE query with COUNT(*) OVER (PARTITION BY parent_id) to expose child counts. Systems such as Reddit may use a materialized path, storing ancestor IDs like '1/2/3/' in a path column, so descendants can be retrieved with LIKE '1/%'.
QUESTION 4
Aggregate Folder Sizes — Roll Up Descendant Sizes into Parent Folders
WITH RECURSIVEAggregationFolder StructureSize Rollup
Background

File-system folders form a hierarchy, and each folder's size is the sum of files directly inside it and files in every descendant folder. This can be implemented by expanding every path with a recursive CTE and aggregating with GROUP BY.

The idea is that each file contributes not only to its own folder but also to every ancestor folder, so first build a (file, ancestor folder) mapping by bottom-up recursion and then sum it with GROUP BY.

-- Step 1: expand (folder_id, ancestor_folder_id) for each file
WITH RECURSIVE folder_ancestors AS ( ... )
-- Step 2: sum file sizes by ancestor_folder_id
SELECT ancestor_folder_id, SUM(file_size) AS total_size
FROM  folder_ancestors
GROUP BY ancestor_folder_id;
Problem

There are two tables for managing folders and files. Find each folder's total size (the sum of all file sizes below it). Return folder_id, folder_name, total_size_kb, sorted by folder_id ascending.

Tables used
▸ folders
folder_idfolder_nameparent_id
1RootNULL
2Documents1
3Images1
4Work2
5Personal2
▸ files
file_idfile_namefolder_idsize_kb
1report.pdf4500
2notes.txt410
3resume.docx5200
4photo1.jpg31500
5photo2.jpg32000
Expected Output

Expected output (folder_id ascending):

folder_idfolder_nametotal_size_kb
1Root4210
2Documents710
3Images3500
4Work510
5Personal200

Root = Documents(710) + Images(3500) = 4210. Documents = Work(510) + Personal(200) = 710.

Model Answer
WITH RECURSIVE folder_tree AS (

  -- ① Anchor member: register each folder itself as its own ancestor
  SELECT
    folder_id AS descendant_id,  -- folder ID on the descendant side
    folder_id AS ancestor_id     -- include the folder itself as an ancestor
  FROM  folders

  UNION ALL

  -- ② Recursive member: generate (descendant, ancestor) pairs from child folders to parent folders
  SELECT
    ft.descendant_id,              -- keep the original descendant folder ID
    f.parent_id AS ancestor_id    -- the parent folder becomes the new ancestor
  FROM  folder_tree ft
  JOIN  folders f ON f.folder_id = ft.ancestor_id
  WHERE f.parent_id IS NOT NULL   -- stop after reaching the root

)
SELECT
  fo.folder_id,
  fo.folder_name,
  SUM(fi.size_kb) AS total_size_kb  -- sum all file sizes mapped to the ancestor folder
FROM  folder_tree ft
JOIN  files fi ON fi.folder_id = ft.descendant_id  -- files belong on the descendant side
JOIN  folders fo ON fo.folder_id = ft.ancestor_id  -- retrieve the folder name from the ancestor side
GROUP BY fo.folder_id, fo.folder_name
ORDER BY fo.folder_id;

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iteration 1
  3. Recursion iteration 2
  4. Recursion iteration 3: all NULL
  5. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE folder_tree AS ( SELECT folder_id AS descendant_id, folder_id AS ancestor_id FROM folders UNION ALL SELECT ft.descendant_id, f.parent_id AS ancestor_id FROM folder_tree ft JOIN folders f ON f.folder_id = ft.ancestor_id WHERE f.parent_id IS NOT NULL ) SELECT fo.folder_id, fo.folder_name, SUM(fi.size_kb) AS total_size_kb FROM folder_tree ft JOIN files fi ON fi.folder_id = ft.descendant_id JOIN folders fo ON fo.folder_id = ft.ancestor_id GROUP BY fo.folder_id, fo.folder_name ORDER BY fo.folder_id;
LEGEND
Rows read / loaded
① Anchor Member (Generate Self-Reference Pairs)
Register every folder as (descendant_id=self, ancestor_id=self)Register each folder as its own ancestor: (1,1), (2,2), (3,3), (4,4), and (5,5). These 5 pairs form the initial working table. This includes each folder's own files in the final aggregation.
1 / 9
descendant_id (descendant)ancestor_id (ancestor)
11
22
33
44
55
Anchor: 5 pairs (self-reference)
(descendant, ancestor) Pairs and File Aggregation
The Basic Rule of Recursion Only newly generated rows become the next working table (input)
Generated (descendant, ancestor) Pairs
descendantancestorWhen Added
4 (Work)4Anchor
4 (Work)2Recursion 1
4 (Work)1Recursion 2
Folders Receiving Work-File Contributions
Filesize_kbContributing Folders
report.pdf500Work, Documents, Root
notes.txt10Work, Documents, Root
LEARNING POINTS
Closure Table pattern: The (descendant_id, ancestor_id) pair set generated by this CTE is a design pattern called a closure table. If the hierarchy changes infrequently, materialize this pair set as a physical table so aggregation queries reduce to a fast JOIN + GROUP BY.
Why include self-reference in the anchor: Including folder_id AS descendant_id, folder_id AS ancestor_id lets files directly inside a folder participate in SUM aggregation. Without self-reference, files in leaf folders are not counted.
Folders without files do not appear in the result: This query uses an INNER JOIN to files, so empty folders are excluded. To include them, use LEFT JOIN files with COALESCE(SUM(size_kb), 0).
ANTI-PATTERNS
Forgetting self-reference omits files: If the anchor is written so that descendant_id ≠ ancestor_id, files directly inside leaf folders (Work, Personal, Images) do not participate in aggregation. The self-reference is required to aggregate a folder's own files.
Continuing after reaching the root: If WHERE f.parent_id IS NOT NULL is omitted, recursion is attempted for the root even though parent_id is NULL. The JOIN may return zero rows, but some DBMSs can warn or time out. Write the root termination condition explicitly.
Field Notes: Recursive SQL Aggregation vs. Application Aggregation
Folder-size rollups can also be computed with recursive application code. A recursive CTE completes the work in one query without round trips, but deeper hierarchies increase expansion rows and memory use. A CTE is practical for a few thousand nodes and up to about 10 levels; beyond that, consider persisting a materialized path or closure table ahead of time.
QUESTION 5
Aggregate Everyone Under Each Manager — Compare Direct and Total Reports
WITH RECURSIVECOUNT AggregationOrganization ChartSpan-of-Control Analysis
Background

In an organization chart where reports form chains beneath managers, distinguish direct reports from all direct and indirect reports (span of control). Expand all descendants with WITH RECURSIVE and then aggregate with COUNT to solve this pattern.

WITH RECURSIVE subordinates AS (
  -- Anchor: start from each employee and expand rows whose manager_id matches
  SELECT emp_id, manager_id, 0 AS depth, emp_id AS root_manager_id
  FROM  employees
  UNION ALL
  SELECT e.emp_id, e.manager_id, s.depth + 1, s.root_manager_id
  FROM  employees e JOIN subordinates s ON e.manager_id = s.emp_id
)
SELECT root_manager_id, COUNT(*) - 1 AS total_subordinates  -- -1 excludes the employee itself
FROM  subordinates
GROUP BY root_manager_id;
Multi-root expansion with root_manager_id:Use all employees as anchors and expand reports with each employee as a root. root_manager_id labels which manager tree each row came from and is used by the subsequent GROUP BY.
Problem

The employees table stores employee-manager relationships. Find each manager's direct report count (direct_reports) and total report count (total_subordinates, including direct and indirect reports). Return emp_id, emp_name, direct_reports, total_subordinates only for employees with at least one direct report, sorted by emp_id ascending.

Tables used
▸ employees
emp_idemp_namemanager_id
1Alice (CEO)NULL
2Bob1
3Carol1
4Dave2
5Eve2
6Frank3
Organization chart:
Alice(1) CEO
├ Bob(2) → Dave(4), Eve(5)
└ Carol(3) → Frank(6)

Alice's direct reports = 2; total reports = 5 (Bob, Carol, Dave, Eve, Frank)
Bob's direct reports = 2; total reports = 2 (Dave, Eve)
Expected Output

Expected output (emp_id ascending; only employees with at least one direct report):

emp_idemp_namedirect_reportstotal_subordinates
1Alice (CEO)25
2Bob22
3Carol11

Dave, Eve, and Frank are excluded because they have no reports.

Model Answer
WITH RECURSIVE sub_tree AS (

  -- ① Anchor member: register all employees as roots of their own trees
  SELECT
    emp_id,
    manager_id,
    emp_id AS root_id,  -- label showing which manager tree this row belongs to
    0      AS depth
  FROM  employees

  UNION ALL

  -- ② Recursive member: add employees whose manager_id is the previous working row's emp_id
  SELECT
    e.emp_id,
    e.manager_id,
    s.root_id,           -- propagate root_id (keep the tree owner unchanged)
    s.depth + 1
  FROM  employees e
  JOIN  sub_tree s ON e.manager_id = s.emp_id

),
direct AS (                                -- ③ Direct report count: count directly by manager_id
  SELECT
    manager_id,
    COUNT(*) AS direct_cnt
  FROM  employees
  WHERE manager_id IS NOT NULL
  GROUP BY manager_id
),
total AS (                                 -- ④ Total report count: count sub_tree rows with depth > 0 (excluding the employee itself)
  SELECT
    root_id,
    COUNT(*) AS total_cnt
  FROM  sub_tree
  WHERE depth > 0                          -- exclude depth=0 because it is the employee itself
  GROUP BY root_id
)
SELECT
  e.emp_id,
  e.emp_name,
  d.direct_cnt  AS direct_reports,
  t.total_cnt   AS total_subordinates
FROM  employees e
JOIN  direct d ON d.manager_id = e.emp_id  -- employees with direct reports are managers
JOIN  total  t ON t.root_id   = e.emp_id
ORDER BY e.emp_id;

/*
  Execution order (SQL logical evaluation order):
  1. sub_tree CTE (recursion)
  2. direct CTE
  3. total CTE
  4. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE sub_tree AS ( SELECT emp_id, manager_id, emp_id AS root_id, 0 AS depth FROM employees UNION ALL SELECT e.emp_id, e.manager_id, s.root_id, s.depth + 1 FROM employees e JOIN sub_tree s ON e.manager_id = s.emp_id ), direct AS ( SELECT manager_id, COUNT(*) AS direct_cnt FROM employees WHERE manager_id IS NOT NULL GROUP BY manager_id ), total AS ( SELECT root_id, COUNT(*) AS total_cnt FROM sub_tree WHERE depth > 0 GROUP BY root_id ) SELECT e.emp_id, e.emp_name, d.direct_cnt AS direct_reports, t.total_cnt AS total_subordinates FROM employees e JOIN direct d ON d.manager_id = e.emp_id JOIN total t ON t.root_id = e.emp_id ORDER BY e.emp_id;
LEGEND
Rows read / loaded
Excluded / hidden data
① Anchor Member (Register All Employees as Starting Points)
Register every employee with root_id=self and depth=0Register all 6 employees as starting points for their own trees. root_id labels which manager's report tree each row belongs to; each anchor starts with root_id equal to its own emp_id.
1 / 10
emp_idemp_namemanager_idroot_iddepth
1AliceNULL10
2Bob120
3Carol130
4Dave240
5Eve250
6Frank360
Anchor: 6 rows (one starting point per employee)
Multi-Root Expansion Through root_id Propagation
Multi-Root Expansion Use all employees as anchors and propagate root_id to expand every tree simultaneously
Key sub_tree Rows (depth>0)
emp_idroot_iddepth
Bob(2)1(Alice)1
Carol(3)1(Alice)1
Dave(4)1(Alice)2
Eve(5)1(Alice)2
Frank(6)1(Alice)2
Dave(4)2(Bob)1
Eve(5)2(Bob)1
Frank(6)3(Carol)1
COUNT(depth>0) by root_id
root_idCOUNTTotal Reports
1(Alice)5Bob, Carol, Dave, Eve, Frank
2(Bob)2Dave, Eve
3(Carol)1Frank
LEARNING POINTS
Propagating root_id is the key to multi-root expansion: Use all employees as anchors and carry s.root_id unchanged through the recursive member to track which manager tree each row came from. Finally, GROUP BY root_id gives every manager's total report count at once.
Direct report counts do not require recursion: Direct reports are obtained with a simple GROUP BY manager_id. Direct reports need no recursion; all descendants do. Keeping them in separate CTEs and joining them at the end is readable.
Exclude the depth=0 self-reference from COUNT: Because the anchor also registers the employee itself (depth=0), the total CTE overcounts by one unless WHERE depth > 0 excludes the self-reference. Subtracting 1 or excluding it with WHERE depth>0 produces the same result.
ANTI-PATTERNS
Recursive expansion can cause row explosion: Even in this six-person organization, sub_tree expands to anchor 6 rows + recursion 5 rows + 3 rows = 14 rows. In a 1,000-person organization, expansion can reach tens of thousands of rows. For very large hierarchies, check indexes and memory settings.
INNER JOIN removes employees with no reports: Because the outer query uses JOIN direct as an INNER JOIN, Dave, Eve, and Frank are naturally excluded. This is intentional; to show every employee including zero reports, use LEFT JOIN and COALESCE(direct_cnt, 0).
Field Notes: Span-of-Control Analysis in Practice
Comparing direct and total report counts is called Span of Control Analysis, a basic organizational-design metric. Too many direct reports may overload a manager; too few may indicate excessive fragmentation. A large total / direct ratio means a manager oversees a deep hierarchy and may become a bottleneck. HR databases can run this query regularly, display it on dashboards, and monitor organizational health.