SQL Recursive Queries: Hierarchies, Depth and Termination

A recursive query combines an initial result with repeated query steps that consume the previous result, making it possible to traverse hierarchies of variable depth.

Lesson 70 of 102Advanced11 min read

A recursive query combines an initial result with repeated query steps that consume the previous result, making it possible to traverse hierarchies of variable depth.

One self join finds an immediate manager, but a hierarchy can contain any number of levels. A recursive CTE can start at a chosen customer and repeatedly find direct reports. A correct stopping rule is as important as the relationship condition.

This reference adds detail to the main course. You can read it independently: the small database below supplies every table and row used in the examples. Start with the question each query answers, then compare the returned values with the data rather than memorising a line of syntax.

Set up the practice database

Run this setup once in a new practice database, before the examples. It uses four customers and six sales. Ana has two sales, Ben has one, Cara has two, and Dev has none.

Sale 106 has no customer link; sale 104 has an unknown amount. These deliberate test cases expose missing information and unmatched relationships.

SQL
CREATE TABLE customers (
  id INTEGER PRIMARY KEY,
  name VARCHAR(40) NOT NULL,
  city VARCHAR(40),
  manager_id INTEGER
);
CREATE TABLE sales (
  id INTEGER PRIMARY KEY,
  customer_id INTEGER,
  amount DECIMAL(10,2),
  status VARCHAR(20) NOT NULL,
  created_on DATE NOT NULL
);
INSERT INTO customers (id,name,city,manager_id) VALUES
  (1,'Ana','Leeds',NULL),(2,'Ben','Leeds',1),
  (3,'Cara','Porto',1),(4,'Dev',NULL,2);
INSERT INTO sales (id,customer_id,amount,status,created_on) VALUES
  (101,1,10,'paid','2026-01-01'),
  (102,1,30,'pending','2026-01-02'),
  (103,2,20,'paid','2026-01-02'),
  (104,3,NULL,'pending','2026-02-01'),
  (105,3,20,'paid','2026-02-02'),
  (106,NULL,5,'paid','2026-02-03');

An amount of zero would be a known value. The NULL amount here means the value is unknown. A customer with no matching sale differs from a sale whose customer_id is NULL.

The primary keys identify individual records even when amounts or cities repeat. Dates use year-month-day format. The examples specify sorting whenever order matters; insertion order alone does not guarantee display order.

How Recursive queries works

A recursive CTE has an anchor term and a recursive term. The anchor selects starting rows, such as Ana at depth zero. The recursive term joins customers to the preceding team rows using child.manager_id=team.id and increments depth. UNION ALL combines each new level with the accumulated result without removing repeated selected rows automatically. The direction of the relationship determines whether the query walks towards managers or towards descendants.

The recursion ends naturally when an iteration finds no more matches. Real data may contain cycles, such as two people naming each other as manager, so a natural stopping assumption can fail. A depth bound limits expansion for safety but can truncate a legitimate deep tree. Robust hierarchy traversal can carry a visited path or use engine features for cycle detection. UNION rather than UNION ALL can remove some repeated rows, but a changing depth or path column makes those rows distinct and does not automatically solve cycles.

Column types and term restrictions

Define the recursive columns explicitly when it helps type and meaning remain clear. Some engines infer types from the anchor, so a growing path string may need a sufficiently wide cast there. The anchor and recursive terms must have compatible column counts and types. Aggregate, window and join restrictions inside a recursive term vary by dialect. Begin with a simple identity-and-depth traversal before adding elaborate calculations.

Final ORDER BY defines display order. Recursion’s evaluation process should not be treated as a universal depth-first or breadth-first output promise. Ordering by depth and id gives a simple level-by-level display for this sample; it does not produce a fully indented tree in all applications. If the model is a graph with multiple paths to the same node, decide whether the result should retain each path or list each node once. That decision affects deduplication, path tracking and interpretation of totals.

MySQL, PostgreSQL, SQL Server and SQLite

PostgreSQL, MySQL 8+ and SQLite use WITH RECURSIVE. SQL Server uses WITH without the RECURSIVE keyword and supports an OPTION (MAXRECURSION n) query hint. Engine defaults and recursive-term restrictions differ. PostgreSQL offers SEARCH and CYCLE features, while equivalent path or cycle handling in other engines uses different mechanisms.

Check the engine-specific syntax and version requirements in PostgreSQL recursive WITH, SQLite recursive CTEs.

Practice task and worked solution

Traverse Ben’s subtree starting at customer 2. Use the same depth bound and order by depth and id.

Try the task before reading its worked solution. The supplied rows make the expected answer checkable by hand.

SQL
WITH RECURSIVE team (id,name,manager_id,depth) AS (
 SELECT id,name,manager_id,0 FROM customers WHERE id=2
 UNION ALL
 SELECT c.id,c.name,c.manager_id,t.depth+1
 FROM customers AS c JOIN team AS t ON c.manager_id=t.id
 WHERE t.depth<10
)
SELECT id,name,depth FROM team ORDER BY depth,id;
Output
id | name | depth
2 | Ben | 0
4 | Dev | 1

Changing the anchor changes the root-relative depths. Ben is now level zero and Dev is level one. Ana and Cara are not descendants of Ben, so they are absent. In SQL Server adapt the WITH spelling and its documented recursion controls before running this solution.

Start from Cara and predict one row because she has no direct reports. Start from Ben and predict Ben followed by Dev at root-relative depths zero and one. On a separate disposable hierarchy introduce a cycle only after implementing a depth guard or cycle policy, then observe whether the query signals truncation or repeated visits. Avoid unbounded experiments on cyclic input.

SQL Recursive Queries: Hierarchies, Depth and Termination Syntax

Recursive queries

WITH RECURSIVE team (id,name,manager_id,depth) AS (
 SELECT id,name,manager_id,0 FROM customers WHERE id=1
 UNION ALL
 SELECT c.id,c.name,c.manager_id,t.depth+1
 FROM customers AS c JOIN team AS t ON c.manager_id=t.id
 WHERE t.depth<10
)
SELECT id,name,depth FROM team ORDER BY depth,id;

SQL Recursive Queries: Hierarchies, Depth and Termination Summary

  • A recursive CTE has an anchor query that selects the starting rows and a recursive query that finds the next level.
  • UNION ALL combines each new level with the rows found so far, until a step returns no rows.
  • Join in the right direction: child.manager_id = parent.id finds descendants; the reverse finds ancestors.
  • A depth column and a limit protect against runaway queries, but cycles need their own detection.
  • PostgreSQL, MySQL 8 and SQLite write WITH RECURSIVE; SQL Server writes WITH and limits depth with MAXRECURSION.

SQL Recursive Queries: Hierarchies, Depth and Termination Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Everyone in Ana's reporting line

The anchor starts with Ana. The first recursive level finds Ben and Cara, and the next finds Dev through Ben. No further children exist. The explicit depth guard is a demonstration safety limit rather than proof that arbitrary input has no cycles.

SQL
WITH RECURSIVE team (id,name,manager_id,depth) AS (
 SELECT id,name,manager_id,0 FROM customers WHERE id=1
 UNION ALL
 SELECT c.id,c.name,c.manager_id,t.depth+1
 FROM customers AS c JOIN team AS t ON c.manager_id=t.id
 WHERE t.depth<10
)
SELECT id,name,depth FROM team ORDER BY depth,id;
Output
id | name | depth
1 | Ana | 0
2 | Ben | 1
3 | Cara | 1
4 | Dev | 2

Common SQL Recursive Queries: Hierarchies, Depth and Termination Mistakes

Walking the wrong direction and ignoring cycles

Joining c.id=t.manager_id walks towards parents rather than finding descendants. An unbounded query on cyclic data can keep generating rows, and adding depth makes repeated visits look distinct. Use the correct relationship direction and an explicit cycle policy; a depth cap alone can hide incomplete results.

SQL Recursive Queries: Hierarchies, Depth and Termination in Real Projects

Category trees and reporting lines

A category browser can fetch all descendants of a chosen category without assuming a fixed number of levels. An organisational report can walk reporting lines similarly. Both need a rule for cycles or multiple parents and a display order that the interface understands independently of the recursion’s execution order.

Treat hierarchy shape as a data requirement: a tree, a graph with multiple paths and a cyclic graph need different traversal policies. Report any depth-limit truncation rather than silently calling the result complete. A visited-path or engine cycle feature can distinguish legitimate shared descendants from repeated visits, while final sorting remains a presentation decision.

SQL Recursive Queries: Hierarchies, Depth and Termination FAQ

Does recursive mean infinite?

No. A correct recursive traversal stops when no new qualifying rows remain, for example when the deepest employees have no reports, or when a deliberate stopping condition such as depth < 10 is reached.

Does UNION always prevent cycles?

Not if changing depth or path values make revisited rows distinct: UNION only removes rows that are identical in every column. Track the visited ids in a path column and stop when the next id is already in the path, or use PostgreSQL's CYCLE clause.

Is a depth cap sufficient validation?

It limits work but can truncate valid data in a genuinely deep hierarchy. Detect cycles and report truncation separately when completeness matters, so a cut-off result isn't mistaken for the full tree.

How do I list the path from the top to each row?

Carry a path column: in the anchor, CAST(name AS VARCHAR(200)) AS path, and in the recursive part, t.path || ' > ' || c.name (or CONCAT in MySQL). Each row then shows its full route, such as Ana > Ben > Dev, which is also handy for sorting a tree display.

Can a recursive CTE generate a series of numbers or dates?

Yes. Start with one value and add one in the recursive part until a limit: WITH RECURSIVE days(d) AS (SELECT DATE '2026-01-01' UNION ALL SELECT d + 1 FROM days WHERE d < DATE '2026-01-31'). PostgreSQL's generate_series does the same more simply.

SQL Recursive Queries: Hierarchies, Depth and Termination Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choicemedium

What root-relative depth does Dev have when the anchor is Ben?

SQL Recursive Queries: Hierarchies, Depth and Termination Quiz

2 questions to check what you learned.

  1. What selects the starting rows?

    • The recursive term without any anchor input
    • The anchor term
    • The final ORDER BY clause
  2. Which comparison finds direct descendants in this model?

    • child.id = current_node.manager_id, which walks toward parents
    • child.id = current_node.id, which repeatedly matches the same node
    • child.manager_id = current_node.id