Trees and sequences: WITH RECURSIVE
In short: WITH RECURSIVE is a query with a step that reads its own result, so it can walk trees such as categories or generate a sequence of dates.
Problem
The path to the category
Above every product the website shows the path to it: “Food > Dairy”. The categories are a tree: each has a parent_id that points to the category above it. With a self join from the module on joins it goes like this:
This works because the tree has two levels. If tomorrow “Cheeses” appears under “Dairy”, you need one more join, and for four levels one more again. You do not know in advance how many levels there will be.
The idea
A step that reads itself
WITH RECURSIVE lets a step read its own rows. Here is the smallest example, the numbers from 1 to 5:
The step has two parts, joined by UNION ALL:
- The start
SELECT 1gives the first row. - The recursive part
SELECT x + 1 FROM n WHERE x < 5takes the rows from the previous round and makes new ones: from 1 it makes 2, from 2 it makes 3.
This repeats until the recursive part returns nothing: at x = 5 the condition is no longer true. Such a step is called a recursive CTE.
Without WHERE x < 5 the query would never stop. Every recursive part needs a condition that sooner or later stops it.
The idea
The tree of categories
Now the path. The start is the main categories (no parent). The recursive part finds the children of everything found so far and adds their name to the path:
In the first round there are “Food” and “Home” (level 1). In the second there are the six subcategories (level 2), each with the path of its parent, “ > ” and its own name. In the third round there are no children and everything stops. If the tree gets deeper, the same query will reach the new levels too.
Try it
Everything in “Food”
Show the name and price of all the products in “Food”, together with its subcategories. Get the category numbers with a recursive step, not by hand. The field starts with the start of the step.
Solution
WITH RECURSIVE sub(id) AS (
SELECT id FROM categories WHERE name = 'Food'
UNION ALL
SELECT c.id FROM categories c JOIN sub s ON c.parent_id = s.id
)
SELECT name, price FROM products
WHERE category_id IN (SELECT id FROM sub);The start is “Food”, the recursive part adds its children, and the products are found with category_id IN (SELECT id FROM sub). 18 products. “Food” itself has no products, but it is in the step and does no harm.
See it
The days without sales
The manager wants a chart of the sales by day for October 2026. The first attempt:
Five rows. October has 31 days, and the chart would join 3 October straight to the 5th and hide that nothing was sold from 7 to 17 October. A day without orders is not in orders, so GROUP BY has nothing to make a row from.
The idea
A sequence of dates
A recursive step can make all the days by itself. The start is 1 October, and every round adds one day with date(day, '+1 day'):
31 rows. Now the sales are attached to the days with LEFT JOIN, and for the days without orders COALESCE puts 0:
All 31 days are here, including the ones with zero. The order of the LEFT JOIN matters: on the left are the days, which you want all of, on the right are the orders, which may be missing.
More precisely, if you are curious
PostgreSQL has a ready-made function for a sequence: generate_series(DATE '2026-10-01', DATE '2026-10-31', INTERVAL '1 day'). In SQLite and in MySQL (from version 8) such a sequence is made with WITH RECURSIVE, as here.
Exercise
Two tasks
1. On how many days in October 2026 did the shop have no orders at all?
Count the days of October 2026 without a single order, up to the end of the month, including the days after the last order (column quiet_days). The field already has the sequence of days.
Solution
WITH RECURSIVE days(day) AS (
SELECT '2026-10-01'
UNION ALL
SELECT date(day, '+1 day') FROM days WHERE day < '2026-10-31'
)
SELECT COUNT(*) AS quiet_days FROM days d
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.ordered_at = d.day);WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.ordered_at = d.day) and COUNT(*): 26 days. There are orders on only 5 days.
2. The website wants the full path for every product: “Food > Dairy > White cheese”.
Show the name of every product that has a category and its full path (column breadcrumb), from the main category to the product name. The field already has the tree.
Solution
WITH RECURSIVE tree(id, name, path, level) AS (
SELECT id, name, name, 1
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, t.path || ' > ' || c.name, t.level + 1
FROM categories c
JOIN tree t ON c.parent_id = t.id
)
SELECT p.name, t.path || ' > ' || p.name AS breadcrumb
FROM products p
JOIN tree t ON t.id = p.category_id;JOIN tree t ON t.id = p.category_id and t.path || ' > ' || p.name. 24 rows: the gift card has no category and JOIN leaves it out. With LEFT JOIN it would be in, but with NULL for the path.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: LEFT JOIN: rows without a match too · Many tables, and a table joined to itself · Subqueries · A report in steps: WITH
- Continues in: Graph databases
- Tasks on this topic: Subqueries and windows