Common Table Expressions (CTEs)
Recursive CTEs
A normal CTE defines a result and then lets the rest of the query use it.
A recursive CTE goes further.
It allows a CTE to refer to its own output.
This makes recursive CTEs useful for data where the number of relationships we need to follow is not known in advance.
Common examples include:
12345category treesorganization chartsfolder structurescomment threadsproduct hierarchiesA hierarchy
Our existing tables don't contain a hierarchical relationship, so we'll create a small temporary table for this chapter.
Run:
12345CREATE TEMP TABLE demo_categories ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, parent_id INTEGER REFERENCES demo_categories(id));Now insert this hierarchy:
12345678INSERT INTO demo_categories (id, name, parent_id)VALUES (1, 'Electronics', NULL), (2, 'Computers', 1), (3, 'Phones', 1), (4, 'Laptops', 2), (5, 'Desktops', 2), (6, 'Gaming Laptops', 4);The data represents:
123456Electronics├── Computers│ ├── Laptops│ │ └── Gaming Laptops│ └── Desktops└── PhonesEach row contains only its immediate parent.
For example:
12Gaming Laptopsparent_id = 4and category 4 is:
1LaptopsThe problem
Suppose we start with:
1Electronicsand want to find every category underneath it.
We don't know beforehand how many levels the hierarchy contains.
It might be:
1231 level3 levels10 levelsA fixed number of joins is therefore not a good solution.
This is where a recursive CTE is useful.
WITH RECURSIVE
Run:
123456789101112131415161718192021222324252627WITH RECURSIVE category_tree AS ( SELECT id, name, parent_id, 0 AS depth FROM demo_categories WHERE id = 1
UNION ALL
SELECT c.id, c.name, c.parent_id, ct.depth + 1 FROM demo_categories c INNER JOIN category_tree ct ON c.parent_id = ct.id)SELECT id, name, parent_id, depthFROM category_treeORDER BY depth, id;You should see:
12345678 id | name | parent_id | depth----+----------------+-----------+------- 1 | Electronics | NULL | 0 2 | Computers | 1 | 1 3 | Phones | 1 | 1 4 | Laptops | 2 | 2 5 | Desktops | 2 | 2 6 | Gaming Laptops | 4 | 3The query discovered every level of the hierarchy.
The two parts of a recursive CTE
A recursive CTE usually contains two main parts joined by:
1UNION ALLThe first is the non-recursive term:
1234567SELECT id, name, parent_id, 0 AS depthFROM demo_categoriesWHERE id = 1This gives PostgreSQL the rows to start with.
In our example, it returns:
1ElectronicsThe second part is the recursive term:
12345678SELECT c.id, c.name, c.parent_id, ct.depth + 1FROM demo_categories cINNER JOIN category_tree ct ON c.parent_id = ct.idNotice that this part references:
1category_treeinside the definition of category_tree itself.
That self-reference is what makes the CTE recursive.
123456789101112131415non-recursive term │ ▼ starting rows │ ▼ recursive term │ ▼ new rows │ ├──────────┐ │ │ └──────────┘ repeat while new rows existHow PostgreSQL evaluates it
Although the syntax is recursive, PostgreSQL evaluates a recursive CTE iteratively.
Let's follow our example.
First step. The non-recursive term finds:
1ElectronicsThat becomes the starting result.
123working rowsElectronicsNext iteration. PostgreSQL runs the recursive term using Electronics.
It looks for rows where:
1parent_id = Electronics.idand finds:
12ComputersPhonesThose become the next working rows.
Next iteration. PostgreSQL now looks for children of:
12ComputersPhonesand finds:
12LaptopsDesktopsNext iteration. It looks for children again and finds:
1Gaming LaptopsFinal iteration. Gaming Laptops has no children.
The recursive term produces no new rows.
The process ends.
Conceptually:
1234567891011121314151617181920212223242526Iteration 0Electronics ↓Iteration 1ComputersPhones ↓Iteration 2LaptopsDesktops ↓Iteration 3Gaming Laptops ↓no more rows ↓stopThe termination condition
A recursive CTE must eventually stop producing rows.
In our query, the relationship:
1c.parent_id = ct.ideventually reaches categories with no children.
At that point, the recursive term produces no rows and PostgreSQL stops iterating.
This is extremely important.
If the recursive logic continues producing rows indefinitely, the query can continue indefinitely.
Tracking depth
We added:
10 AS depthto the starting row.
Then every recursive step uses:
1ct.depth + 1This produces:
123456Electronics depth 0Computers depth 1Phones depth 1Laptops depth 2Desktops depth 2Gaming Laptops depth 3The depth column was not stored in demo_categories.
It is calculated while the recursive CTE traverses the hierarchy.
This is useful when we want to know how far each result is from the starting row.
Starting from a different category
The same query can start anywhere in the hierarchy.
For example, change:
1WHERE id = 1to:
1WHERE id = 2Now the traversal begins at:
1Computersand returns:
123456 id | name | parent_id | depth----+----------------+-----------+------- 2 | Computers | 1 | 0 4 | Laptops | 2 | 1 5 | Desktops | 2 | 1 6 | Gaming Laptops | 4 | 2It does not return Phones because Phones is not a descendant of Computers.
Notice that depth is relative to the starting row, so Computers is now 0 rather than 1.
UNION ALL versus UNION
Recursive CTEs can use either:
1UNION ALLor:
1UNIONUNION ALL keeps every row produced by the recursion.
UNION removes duplicate rows.
For tree structures where each relationship is traversed once, UNION ALL is commonly appropriate and avoids unnecessary duplicate elimination.
Using UNION can help eliminate repeated identical rows in some recursive queries, but it should not be treated as a complete solution to every possible cycle.
Be careful with cycles
Trees have a natural direction:
12345parent ↓child ↓grandchildBut graph-like data can contain cycles.
For example:
123A → BB → CC → AA recursive query that keeps following those relationships could revisit the same rows repeatedly.
For recursive queries over data where cycles are possible, the query needs a strategy for detecting previously visited rows.
PostgreSQL also provides more advanced features for search order and cycle detection, but those are beyond what we need for the core recursive CTE pattern.
The important thing to remember is:
A recursive query must have a path to termination.
Recursion is useful when depth is unknown
If we knew that a hierarchy had exactly two levels, we could potentially solve it using a fixed number of joins.
Recursive CTEs become particularly useful when we don't know the depth in advance.
For example:
123456789Electronics ↓Computers ↓Laptops ↓Gaming Laptops ↓...The query continues following the relationship until there are no more matching rows.
Remove the demonstration table:
1DROP TABLE demo_categories;The core mental model
A recursive CTE has:
12345678910111213starting rows │ ▼find related rows │ ▼use those rows to find more rows │ ▼repeat │ ▼stop when no new rows are foundThe important point is:
Recursive CTEs let a query repeatedly follow relationships when the number of levels is not known in advance.