DevLearningTools

LEARN · SQL

Recursive CTEs

A recursive CTE references itself, walking a hierarchy like a management chain to an unknown depth, something a plain JOIN can't do. Learn the anchor/recursive structure, and the real RECURSIVE-keyword split by database.

Module 4's self-join found each employee's direct manager, one level up. It can't find everyone under a given manager at every level, 2nd-line reports, 3rd-line reports, however deep the chain goes, a plain JOIN only ever reaches a fixed number of levels. A recursive CTE reaches all of them, however deep that actually is.

Learning Objectives

  • Define a recursive CTE, and explain the anchor member / recursive member structure.
  • Write a recursive CTE walking Beacon Robotics' manager hierarchy, correctly on multiple databases.
  • Know which databases require the literal word RECURSIVE, and which don't use it at all.

Anchor Member

The base case: manager_id IS NULL

Recursive Member

Joins back to the CTE itself

Repeats

Until a step produces no new rows

Full Hierarchy

Every level, however deep

The Anchor and Recursive Members

A recursive CTE has two parts, joined by UNION ALL: the anchor member, a normal query that produces the starting rows, and the recursive member, a query that joins back to the CTE's own name, pulling in one more level each time it runs. The database keeps re-running the recursive member, feeding each round's results into the next, until a round produces zero new rows.

Walking the Full Manager Hierarchy

The anchor finds every employee with no manager (depth 1, the top of the chain). The recursive member joins employees back to org_chart itself, finding each next level's direct reports and incrementing depth, round after round, until a round finds nobody left to add.

MySQL (8+) / MariaDB (10.2+) / PostgreSQL / SQLite (3.8.3+)
WITH RECURSIVE org_chart AS (
  SELECT id, first_name, manager_id, 1 AS depth
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  SELECT e.id, e.first_name, e.manager_id, oc.depth + 1
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart
ORDER BY depth;
SQL Server / Oracle (11g R2+)
WITH org_chart AS (
  SELECT id, first_name, manager_id, 1 AS depth
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  SELECT e.id, e.first_name, e.manager_id, oc.depth + 1
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart
ORDER BY depth;

The Real RECURSIVE Keyword Split

DatabaseRECURSIVE Keyword
MySQL / MariaDB / PostgreSQL / SQLiteRequired: WITH RECURSIVE cte_name AS (...)
SQL Server / OracleNot used at all: plain WITH cte_name AS (...), recursion is detected automatically
NOTE

Writing WITH RECURSIVE on SQL Server or Oracle is a syntax error on both, they have no RECURSIVE keyword in their WITH clause grammar whatsoever. Both still fully support recursive CTEs, they just detect the self-reference automatically from the CTE calling its own name inside itself, with no keyword needed to flag it.

Guarding Against an Infinite Loop

A recursive CTE with no natural termination, or a cycle in the underlying data (A manages B, B manages A), can run indefinitely. SQL Server is the one database here with a built-in safety net: a default MAXRECURSION limit of 100 levels, erroring out past that unless explicitly raised with OPTION (MAXRECURSION 1000) or removed entirely with OPTION (MAXRECURSION 0). The other five databases have no equivalent built-in cap, a genuinely cyclic query there can run until manually stopped.

Try It Yourself

Write a recursive CTE listing every employee who reports, directly or indirectly, to the employee with id 1, on PostgreSQL.

Answer
WITH RECURSIVE reports AS (
  SELECT id, first_name, manager_id
  FROM employees
  WHERE manager_id = 1

  UNION ALL

  SELECT e.id, e.first_name, e.manager_id
  FROM employees e
  JOIN reports r ON e.manager_id = r.id
)
SELECT * FROM reports;

Frequently Asked Questions

Why UNION ALL instead of plain UNION between the anchor and recursive members?

UNION ALL avoids a deduplication check on every single round of recursion, which matters for performance across a deep hierarchy. UNION is occasionally used deliberately instead, specifically to help guard against a cycle in the data producing the exact same row forever, at the cost of that extra per-round check.

Can a recursive CTE compute something other than a hierarchy, like a sequence of numbers?

Yes, a recursive CTE whose anchor starts at 1 and whose recursive member adds 1 each round (with a WHERE clause capping it) generates a simple number sequence, the hierarchy-walking case here is the most common real use, but the mechanism itself is general-purpose.

Common Beginner Mistakes

Writing WITH RECURSIVE on SQL Server or Oracle

Both reject the RECURSIVE keyword outright, it doesn't exist in either's WITH clause grammar. Plain WITH cte_name AS (...) is correct on both, the recursion is detected automatically from the self-reference, no keyword required.

Forgetting a terminating condition and looping forever on cyclic data

If the underlying data has a genuine cycle (A reports to B, B reports to A), a recursive CTE with no extra safeguard will keep finding "new" rows forever on five of these six databases. SQL Server's default MAXRECURSION of 100 is the only built-in safety net here, the other five need the data itself to be acyclic, or an explicit depth cap added to the recursive member.

Best Practices

  • Always include a depth or path column, incrementing it in the recursive member, both to track levels and to cap runaway recursion if needed.
  • Test a recursive CTE against a small, known subset of data first, before running it against the full table.
  • Remember the RECURSIVE keyword's split: required on MySQL/MariaDB/PostgreSQL/SQLite, absent (and invalid) on SQL Server/Oracle.

Interview Questions

Write a recursive CTE to find every employee reporting, at any depth, to a given manager.

WITH RECURSIVE reports AS (SELECT id, first_name, manager_id FROM employees WHERE manager_id = :managerId UNION ALL SELECT e.id, e.first_name, e.manager_id FROM employees e JOIN reports r ON e.manager_id = r.id) SELECT * FROM reports; — the anchor finds direct reports, the recursive member repeatedly joins employees back to the CTE's own growing result, finding each next level down, until a round finds no more matches.

Why does WITH RECURSIVE fail on SQL Server, even though SQL Server fully supports recursive CTEs?

SQL Server's WITH clause grammar has no RECURSIVE keyword at all, recursion there is detected automatically purely from the CTE referencing its own name inside its own definition. The correct SQL Server syntax is a plain WITH cte_name AS (...), identical in structure to the recursive version on other databases, just without that one keyword, Oracle behaves the same way.

Summary

A recursive CTE references itself, an anchor member provides the starting rows, a recursive member joins back to the CTE's own name, repeating until a round produces nothing new, the only way to walk a hierarchy to an unknown depth. MySQL, MariaDB, PostgreSQL, and SQLite require the literal RECURSIVE keyword, SQL Server and Oracle don't have it at all and detect the self-reference automatically. SQL Server's default MAXRECURSION of 100 is the one built-in runaway-recursion safeguard among these six.

That completes the Common Table Expressions module: naming a result with WITH instead of nesting a subquery, chaining multiple named steps, and recursion for the one problem a plain JOIN genuinely can't solve.

What's Next?

Module 13, Indexes and Performance, is next: how an index actually makes a lookup fast, and reading an execution plan to see it for real.

</>

Ready to Practice SQL?

Write and run real SQL queries online, right in your browser, no install or signup required. Pick a playground based on which database you want to practice on.

SQLite / PostgreSQL

Fast, no server, no ads.

Open DB Fiddle ↗

MySQL / SQL Server

Switch databases with one dropdown.

Open SQL Fiddle ↗

Both open a third-party SQL playground in a new tab. External site terms and privacy policy apply.

Or try 50 SQL practice questions →