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;
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.
| dept_id | dept_name | parent_id |
|---|---|---|
| 1 | Company | NULL |
| 2 | Engineering | 1 |
| 3 | Sales | 1 |
| 4 | Backend | 2 |
| 5 | Frontend | 2 |
| 6 | Domestic Sales | 3 |
| 7 | International Sales | 3 |
Company(1)
├ Engineering(2)
│ ├ Backend(4)
│ └ Frontend(5)
└ Sales(3)
├ Domestic Sales(6)
└ International Sales(7)
Expected output (dept_id ascending):
| dept_id | dept_name | parent_id | depth | path |
|---|---|---|---|---|
| 1 | Company | NULL | 0 | Company |
| 2 | Engineering | 1 | 1 | Company/Engineering |
| 3 | Sales | 1 | 1 | Company/Sales |
| 4 | Backend | 2 | 2 | Company/Engineering/Backend |
| 5 | Frontend | 2 | 2 | Company/Engineering/Frontend |
| 6 | Domestic Sales | 3 | 2 | Company/Sales/Domestic Sales |
| 7 | International Sales | 3 | 2 | Company/Sales/International Sales |
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 */
LEGEND
① 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.| dept_id | dept_name | parent_id | depth | path |
|---|---|---|---|---|
| 1 | Company | NULL | 0 | Company |
| dept_id | depth | path |
|---|---|---|
| 1 Company | 0 | Company |
| dept_id | depth |
|---|---|
| 1 Company | 0 |
| dept_id | depth | path |
|---|---|---|
| 1 Company | 0 | Company |
| 2 Engineering | 1 | Company/Engineering |
| 3 Sales | 1 | Company/Sales |
| dept_id | depth |
|---|---|
| 2 Engineering | 1 |
| 3 Sales | 1 |
| dept_id | depth | path |
|---|---|---|
| 1 Company | 0 | … |
| 2 Engineering | 1 | … |
| 3 Sales | 1 | … |
| 4 Backend | 2 | Company/Engineering/… |
| 5 Frontend | 2 | Company/Engineering/… |
| 6 Domestic Sales | 2 | Company/Sales/… |
| 7 International Sales | 2 | Company/Sales/… |
| Reason |
|---|
| No children exist for dept_id 4–7 Added rows = 0 → loop ends |
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 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.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.CYCLE dept_id SET is_cycle USING path (PG14+); a guard such as depth < 10 is also commonly added to WHERE.FROM dept_tree a JOIN dept_tree b. A recursive member may self-reference the CTE only once.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.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;
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.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.
| category_id | category_name | parent_id |
|---|---|---|
| 1 | ALL | NULL |
| 2 | Fashion | 1 |
| 3 | Electronics | 1 |
| 4 | Men | 2 |
| 5 | Women | 2 |
| 6 | Men's Sneakers | 4 |
| 7 | Men's Jacket | 4 |
Expected breadcrumb: ALL → Fashion → Men → Men's Sneakers
Expected output (level DESC = root first):
| category_id | category_name | parent_id | level |
|---|---|---|---|
| 1 | ALL | NULL | 3 |
| 2 | Fashion | 1 | 2 |
| 4 | Men | 2 | 1 |
| 6 | Men's Sneakers | 4 | 0 |
level=0 is the target node itself; larger levels are higher in the hierarchy, toward the root. level DESC puts the root first.
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 */
LEGEND
① 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.| category_id | category_name | parent_id | level |
|---|---|---|---|
| 6 | Men's Sneakers | 4 | 0 |
| Direction | JOIN Condition |
|---|---|
| Top-down | child.parent_id = cte.id |
| Bottom-up ★ | tbl.id = cte.parent_id |
| Iteration | Retrieved Node | level |
|---|---|---|
| anchor | Men's Sneakers(6) | 0 |
| iter1 | Men(4) | 1 |
| iter2 | Fashion(2) | 2 |
| iter3 | ALL(1) | 3 |
| iter4 | (0 rows added → terminate) | — |
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.ORDER BY level DESC puts the root first, producing a natural breadcrumb order (home > category > ... > current location).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).ORDER BY level DESC when the breadcrumb display order must be guaranteed.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 )
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.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.
| comment_id | content | parent_id |
|---|---|---|
| 1 | Interesting article | NULL |
| 2 | I agree | NULL |
| 3 | Please tell me more | 1 |
| 4 | Here is a reference link | 1 |
| 5 | Thanks! | 3 |
| 6 | I think so too | 3 |
| 7 | A deeper reply | 5 |
Expected output (comment_id ascending):
| comment_id | content | parent_id | depth | indent |
|---|---|---|---|---|
| 1 | Interesting article | NULL | 0 | (empty string) |
| 2 | I agree | NULL | 0 | (empty string) |
| 3 | Please tell me more | 1 | 1 | (2 spaces) |
| 4 | Here is a reference link | 1 | 1 | (2 spaces) |
| 5 | Thanks! | 3 | 2 | (4 spaces) |
| 6 | I think so too | 3 | 2 | (4 spaces) |
comment_id=7 (depth=3) is not retrieved. Only 6 rows are returned.
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 */
LEGEND
① 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.| comment_id | content | parent_id | depth | indent |
|---|---|---|---|---|
| 1 | Interesting article | NULL | 0 | '' |
| 2 | I agree | NULL | 0 | '' |
| Code Location | Effect |
|---|---|
| JOIN ... WHERE t.depth+1 < 3 | Blocks recursion itself |
| Code Location | Effect |
|---|---|
| Outer WHERE depth < 3 | Does not stop recursion; only filters the result (risk of an infinite loop) |
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.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.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.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.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/%'.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;
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.
| folder_id | folder_name | parent_id |
|---|---|---|
| 1 | Root | NULL |
| 2 | Documents | 1 |
| 3 | Images | 1 |
| 4 | Work | 2 |
| 5 | Personal | 2 |
| file_id | file_name | folder_id | size_kb |
|---|---|---|---|
| 1 | report.pdf | 4 | 500 |
| 2 | notes.txt | 4 | 10 |
| 3 | resume.docx | 5 | 200 |
| 4 | photo1.jpg | 3 | 1500 |
| 5 | photo2.jpg | 3 | 2000 |
Expected output (folder_id ascending):
| folder_id | folder_name | total_size_kb |
|---|---|---|
| 1 | Root | 4210 |
| 2 | Documents | 710 |
| 3 | Images | 3500 |
| 4 | Work | 510 |
| 5 | Personal | 200 |
Root = Documents(710) + Images(3500) = 4210. Documents = Work(510) + Personal(200) = 710.
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 */
LEGEND
① 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.| descendant_id (descendant) | ancestor_id (ancestor) |
|---|---|
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
| descendant | ancestor | When Added |
|---|---|---|
| 4 (Work) | 4 | Anchor |
| 4 (Work) | 2 | Recursion 1 |
| 4 (Work) | 1 | Recursion 2 |
| File | size_kb | Contributing Folders |
|---|---|---|
| report.pdf | 500 | Work, Documents, Root |
| notes.txt | 10 | Work, Documents, Root |
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.LEFT JOIN files with COALESCE(SUM(size_kb), 0).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.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.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;
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.
| emp_id | emp_name | manager_id |
|---|---|---|
| 1 | Alice (CEO) | NULL |
| 2 | Bob | 1 |
| 3 | Carol | 1 |
| 4 | Dave | 2 |
| 5 | Eve | 2 |
| 6 | Frank | 3 |
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 (emp_id ascending; only employees with at least one direct report):
| emp_id | emp_name | direct_reports | total_subordinates |
|---|---|---|---|
| 1 | Alice (CEO) | 2 | 5 |
| 2 | Bob | 2 | 2 |
| 3 | Carol | 1 | 1 |
Dave, Eve, and Frank are excluded because they have no reports.
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 */
LEGEND
① 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.| emp_id | emp_name | manager_id | root_id | depth |
|---|---|---|---|---|
| 1 | Alice | NULL | 1 | 0 |
| 2 | Bob | 1 | 2 | 0 |
| 3 | Carol | 1 | 3 | 0 |
| 4 | Dave | 2 | 4 | 0 |
| 5 | Eve | 2 | 5 | 0 |
| 6 | Frank | 3 | 6 | 0 |
| emp_id | root_id | depth |
|---|---|---|
| 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 |
| root_id | COUNT | Total Reports |
|---|---|---|
| 1(Alice) | 5 | Bob, Carol, Dave, Eve, Frank |
| 2(Bob) | 2 | Dave, Eve |
| 3(Carol) | 1 | Frank |
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.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.WHERE depth > 0 excludes the self-reference. Subtracting 1 or excluding it with WHERE depth>0 produces the same result.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).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.