SQL example

How to write a recursive query for tree data in SQL

8 min read Updated Oct 2026 Runs in an isolated runtime
Quick answer

Use a recursive CTE: WITH RECURSIVE tree AS (anchor UNION ALL recursive). The anchor SELECT picks the starting row, and the recursive SELECT joins the table back to tree on c.parent_id = t.id, adding one level of children per pass until a pass finds none. SQL Server writes the same query without the word RECURSIVE.

The usual way to store a tree in a table is an adjacency list: each row keeps the id of its parent in a parent_id column, and the root has NULL there. A plain join only reaches one level, and you don't know in advance how deep the tree goes. A recursive common table expression solves that: it runs its second SELECT again and again, each time on the rows the previous pass produced, so one query reaches every level.

Every example below uses the same small categories table, four levels deep, and runs on SQLite; the table in section 7 gives the PostgreSQL, MySQL and SQL Server forms. Other ways to store a tree trade simpler reads for harder writes: a path column (/1/2/4/, queried with LIKE), nested sets (left and right numbers per row) or a closure table (one row per ancestor and descendant pair). The adjacency list with a recursive query is the simplest to keep correct, and it is what this page covers.

1WITH RECURSIVE: every descendant of a nodeRecommended

A recursive CTE has two parts joined by UNION ALL. The anchor member runs once and returns the starting row, here Books. The recursive member refers to the CTE itself: it joins categories to the rows found so far and returns their children. The database repeats it on each new batch of rows and stops when a pass returns nothing. Carrying a depth column, which starts at 0 and grows by 1 per pass, tells you how far below the start each row sits.

descendants.sql

Output

Books comes back at depth 0, its children Fiction and Science at 1, and Fantasy and Crime at 2. Music, Jazz and Shop are left out because they aren't below Books. To get only the descendants, add WHERE depth > 0 to the outer query; to start from the root, make the anchor WHERE parent_id IS NULL. The column list after tree names the CTE's columns, and the anchor decides how many there are. The rows come out in no guaranteed order, so the outer ORDER BY matters.

2A path string and an indented outline

Anything the recursive member computes from the parent row is carried down to the child. Start a path with the root's name and append ' > ' || c.name on each pass, and every row gets its full trail from the root. Sorting by that path puts each child straight after its parent, which is also the order you want for an indented outline: pad the name with two spaces per level.

paths.sql

Output

The paths run from Shop to Shop > Books > Fiction > Fantasy, and the outline prints the same eight rows indented by level. Sorting by a path of names puts siblings in alphabetical order (Crime before Fantasy); if siblings have their own sort column, build the path from that instead. The path is text that grows by one name per level, which SQLite and PostgreSQL (with TEXT) accept as it is. MySQL and SQL Server fix the column's type from the anchor, so the anchor needs a CAST to a wide enough type: see the table in section 7.

3Walk up to the root: the ancestors of a node

To go up instead of down, flip the join. The anchor is the node you start from, and the recursive member finds the row whose id equals the current row's parent_id. A node has one parent, so each pass adds exactly one row until it reaches the root, whose parent_id is NULL and matches nothing. That is how breadcrumbs are built; aggregating the names with string_agg turns them into one string (more on that in combine rows into one string).

ancestors.sql

Output

The walk returns Fantasy, Fiction, Books and Shop at levels 0 to 3, and the breadcrumb reads Shop > Books > Fiction > Fantasy. The ancestors CTE has to carry parent_id, because the next pass joins on it. string_agg with an ORDER BY inside needs SQLite 3.44 or later; PostgreSQL has the same syntax, and MySQL spells it GROUP_CONCAT(name ORDER BY level DESC SEPARATOR ' > ').

4Count the descendants of every node

To answer "how many items sit under each category?" in one query, let the anchor return every row paired with itself, and let the recursive member carry that first id (root_id) down through the tree. The result is one row per pair of a node and something at or below it. Group by root_id and count, subtracting 1 for the node itself.

counts.sql

Output

Shop has 7 descendants (every other row), Books 4, Fiction 2, Music 1, and the four leaves 0. The pairs CTE is a closure table computed on the fly: if you need these counts often on a large tree, storing that table, or a count column you maintain, is cheaper than recomputing it. Swap COUNT(*) for SUM over a joined amount to total the products or sales under each category the same way.

5Guard against cycles

A tree has no loops, but a table can: one bad update that makes a row the child of its own descendant, and a plain recursive query keeps going around the circle forever. There are two portable guards. A depth limit stops after a fixed number of levels. A path check carries the ids visited so far and refuses to step onto one again, which stops exactly where the loop closes. Here Docs, Work and Old point at each other in a circle.

cycles.sql

Output

The depth limit ends the query, but Docs and Work come back a second time at depths 3 and 4. The path check returns each folder once, with paths /2/, /2/3/ and /2/3/4/, and stops when Old points back at 2. Without either guard, this page's run ends with Execution timed out, because SQLite has no recursion limit of its own. If the CTE has no column that changes per pass (no depth, no path), writing UNION instead of UNION ALL also ends a cycle, since it discards rows already produced; see UNION vs UNION ALL. Run on this table, that version returns Docs, Work and Old once each.

6The simplest recursive CTE: a number series

Recursion doesn't need a table at all. An anchor that returns 1 and a recursive member that adds 1 while x < 5 produce a series of numbers, which is the smallest example of the anchor, UNION ALL, recursive-member pattern, and a handy way to make rows to join against (one per day of a month, one per slot in a schedule). The WHERE in the recursive member is what ends it.

series.sql

Output

It prints 1 to 5 with their squares. Each pass sees only the row the previous pass made, so x + 1 counts up by one. Drop the WHERE and the series never ends, the same failure as a cycle in a tree. A series longer than 100 rows on SQL Server, or 1000 on MySQL, runs into their recursion limits, which the table below shows how to raise.

7PostgreSQL, MySQL and SQL Server

The anchor, UNION ALL, recursive-member shape is standard SQL and works everywhere below. The differences are the keyword, how the CTE's column types are fixed, and the built-in recursion limits. Oracle runs recursive WITH without the RECURSIVE keyword and also has its older CONNECT BY PRIOR syntax.

SyntaxDatabaseNotes
WITH RECURSIVE tree AS (… UNION ALL …)PostgreSQL, MySQL 8.0+Same as SQLite. MySQL 5.7 and older have no CTEs at all.
SELECT id, name::text … (anchor)PostgreSQLA VARCHAR(20) name in a growing path fails: "column 2 has type character varying(20) in non-recursive term but type character varying overall". Cast the anchor to text.
CYCLE id SET is_cycle USING visitedPostgreSQL 14+Built-in cycle guard after the CTE: stops at a repeated id and marks that row is_cycle = t.
CONCAT(t.path, ' > ', c.name)MySQL|| means OR in MySQL ('a' || 'b' returns 0). Cast the anchor wide, CAST(name AS CHAR(200)), or a long path fails with error 1406 Data too long.
SET cte_max_recursion_depth = nMySQLDefault 1000. A cycle stops with error 3636: Recursive query aborted after 1001 iterations.
WITH tree AS (… UNION ALL …)SQL ServerNo RECURSIVE keyword: WITH RECURSIVE is a syntax error. Concatenate with +, and CAST both members to the same type, e.g. VARCHAR(400).
OPTION (MAXRECURSION n)SQL ServerGoes after the outer query. SQL Server stops a CTE after 100 levels by default; 0 removes the limit. Not run here: Babelfish accepts the hint but enforces no limit.

Frequently asked questions

How do I get all children of a parent at every level in SQL?

Use a recursive CTE: WITH RECURSIVE tree AS (SELECT id, name FROM categories WHERE id = 2 UNION ALL SELECT c.id, c.name FROM categories c JOIN tree t ON c.parent_id = t.id) SELECT * FROM tree. The first SELECT is the starting row and the second adds one level of children per pass. Add a depth column that starts at 0 and adds 1 per pass to see how deep each row is, and filter depth > 0 to drop the starting row.

What are the anchor member and the recursive member?

They are the two SELECTs inside a recursive CTE, joined by UNION ALL. The anchor member runs once and must not refer to the CTE; it returns the starting rows. The recursive member refers to the CTE by name and runs again on each new batch of rows until it returns none. The anchor fixes the number of columns, and in MySQL, PostgreSQL and SQL Server also their types.

How do I stop a recursive query from looping forever?

Give the recursive member a stopping condition: a depth limit such as WHERE t.depth < 10, or a path of visited ids with WHERE t.path NOT LIKE '%/' || f.id || '/%', which skips a row already on the path. PostgreSQL 14+ has CYCLE id SET is_cycle USING visited for the same job. MySQL aborts after 1000 iterations by default (cte_max_recursion_depth) and SQL Server after 100 levels (OPTION (MAXRECURSION n) changes it); SQLite and PostgreSQL have no limit and run until stopped.

How do I get the full path of each node, like Shop > Books > Fiction?

Carry a path column through the recursion: start it as the root's name in the anchor and append t.path || ' > ' || c.name in the recursive member, then ORDER BY path so each child follows its parent. In MySQL use CONCAT(t.path, ' > ', c.name) and cast the anchor to a wide type such as CAST(name AS CHAR(200)); in SQL Server use + and cast both members to the same VARCHAR length.

What is the best way to store hierarchical data in SQL?

For most trees, an adjacency list (an id and a parent_id per row) plus a recursive CTE: moving a subtree is one UPDATE and nothing can get out of sync. A path column (/1/2/4/) makes "everything under X" a fast LIKE '/1/2/%' but every descendant's path changes on a move. Nested sets make subtree reads a range check but rewrite many rows on each insert. A closure table stores every ancestor and descendant pair, which gives fast reads in both directions at the cost of more rows and more work on writes.

Does MySQL support recursive queries?

Yes, from MySQL 8.0, with the same WITH RECURSIVE syntax as PostgreSQL and SQLite. MySQL 5.7 has no CTEs, so older code walks a tree with a fixed number of self-joins or a stored procedure. Two MySQL details: concatenate with CONCAT because || means OR, and cast the anchor's text columns wide enough, or a growing path fails with Data too long for column 'path'.

Run it yourself

Open any of these in the SQLite playground to change the tree, run and share.

SQLite playground