Recursive CTEs

Learn SQL /Subqueries, CTEs & Set Operations /Lesson 6 of 7 /Advanced /SQLite in your browser
In this lesson you’ll learn to
  • Generate rows with anchor + recursive step
  • Always bound the recursion

Every query you have written so far shares one quiet limitation: it can only return rows that already exist in a table. Ask the shop database “which days in July had no orders?” and you hit a wall, because the quiet days are exactly the ones no table mentions. Recursive CTEs remove that wall. They let a query build its own rows, one from the last, like a calendar, a number series, or a chain of steps, and then treat those generated rows like any other table.

This builds on Common Table Expressions: WITH, where you named subqueries up front and chained CTEs top to bottom.

The Recursive Shape: Anchor, UNION ALL, Recursive Step

A recursive CTE starts with WITH RECURSIVE and always has the same three-part body: an anchor query that produces the starting row, UNION ALL, and a recursive step that selects from the CTE itself to produce the next row from the previous one. The step keeps running on each new row it makes, until its WHERE clause stops matching. Here is the classic first example, counting from 1 to 10:

Before you run it, find the three parts: which line is the anchor, which is the recursive step, and what finally makes it stop?

WITH RECURSIVE counter(n) AS (
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM counter WHERE n < 10
)
SELECT n FROM counter;
n
1
2
3
4
5
6
7
8
9
10

Walk it through. The anchor SELECT 1 runs exactly once and produces the row 1. Then the recursive step takes that row: is 1 < 10? Yes, so it produces 2. It takes 2 and produces 3, and so on. When the step picks up the row 10, the check 10 < 10 fails, no new row is produced, and the recursion ends. The WHERE n < 10 is the termination bound, and it is not optional decoration: every recursive query you write must carry an explicit bound like this in the recursive step.

Bounds are also where off-by-one mistakes hide. The bound is checked against the row you already have, before the new one is built:

Same counter, but the bound is now <= instead of <. How many rows come back?

WITH RECURSIVE counter(n) AS (
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM counter WHERE n <= 10
)
SELECT n FROM counter;
  • 10
  • 11
  • The query never finishes

The bound tests the row that already exists, then builds the next one. Row 10 passes the check 10 <= 10 and produces 11; row 11 fails it and the recursion stops. So <= 10 yields the numbers 1 through 11, which is 11 rows. To stop at 10, bound with n < 10.

No bound means no end. Remove the WHERE from the recursive step and every row produces another row, forever. MySQL gives up with an error after 1,000 iterations by default; SQLite has no such default and will keep generating rows until the tab, the memory, or your patience runs out. SQLite happens to produce rows lazily, so an outer LIMIT can sometimes cut a runaway short, but that is an implementation detail, not a safety feature. Treat the explicit WHERE bound in the recursive step as mandatory, every time.

Generating a Calendar

Counting integers is the warm-up. The same shape generates dates: anchor on a starting date, and let the recursive step add one day at a time with date(day, '+1 day'). Here is the first week of July 2024, seven rows that exist in no table:

WITH RECURSIVE week(day) AS (
  SELECT '2024-07-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM week WHERE day < '2024-07-07'
)
SELECT day FROM week;
day
2024-07-01
2024-07-02
2024-07-03
2024-07-04
2024-07-05
2024-07-06
2024-07-07

The bound works because dates in this database are TEXT in YYYY-MM-DD form, so day < '2024-07-07' compares them correctly. Stretch the bound to '2024-07-31' and you have the whole month. On its own that is a neat trick. Joined to a real table, it answers questions nothing else can.

The Days Nothing Happened

The shop took orders on 8 days in July 2024. A plain query can list those 8 days, but it can never list the quiet ones, because a JOIN, a WHERE, a GROUP BY can only work with rows that exist. Now you can manufacture the missing rows: generate all 31 July days, LEFT JOIN orders onto them, and keep the days where nothing matched.

Notice which side of the LEFT JOIN the generated calendar is on, and why the WHERE looks for a NULL id.

WITH RECURSIVE july(day) AS (
  SELECT '2024-07-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM july WHERE day < '2024-07-31'
)
SELECT july.day
FROM july
LEFT JOIN orders ON orders.order_date = july.day
WHERE orders.id IS NULL;
day
2024-07-04
2024-07-05
2024-07-06
2024-07-07
2024-07-09
2024-07-11
2024-07-13
2024-07-14
2024-07-16
2024-07-17
2024-07-19
2024-07-20
2024-07-21
2024-07-22
2024-07-23
2024-07-24
2024-07-25
2024-07-26
2024-07-27
2024-07-28
2024-07-29
2024-07-30
2024-07-31

The generated calendar is the left table, so every one of the 31 days survives the join. Days with an order get real order columns; days without one get NULLs, and WHERE orders.id IS NULL keeps exactly those. This is the same unmatched-rows pattern you used to find the never-ordered Wool Beanie, except this time the left table came from nowhere. Explore it:

WITH RECURSIVE july(day) AS (
  SELECT '2024-07-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM july WHERE day < '2024-07-31'
)
SELECT COUNT(*) AS quiet_days
FROM july
LEFT JOIN orders ON orders.order_date = july.day
WHERE orders.id IS NULL;
quiet_days
23
WITH RECURSIVE july(day) AS (
  SELECT '2024-07-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM july WHERE day < '2024-07-31'
)
SELECT july.day
FROM july
LEFT JOIN orders ON orders.order_date = july.day
WHERE orders.id IS NULL
ORDER BY july.day
LIMIT 1;
day
2024-07-04
WITH RECURSIVE july(day) AS (
  SELECT '2024-07-01'
  UNION ALL
  SELECT date(day, '+1 day') FROM july WHERE day < '2024-07-31'
)
SELECT july.day, strftime('%w', july.day) AS weekday
FROM july
LEFT JOIN orders ON orders.order_date = july.day
WHERE orders.id IS NULL
  AND strftime('%w', july.day) IN ('0', '6');
day | weekday
2024-07-06 | 6
2024-07-07 | 0
2024-07-13 | 6
2024-07-14 | 0
2024-07-20 | 6
2024-07-21 | 0
2024-07-27 | 6
2024-07-28 | 0

The last chip tells a small story: every single weekend day in July was quiet. All 8 of the shop’s July orders landed on weekdays. That insight was invisible until you generated the rows the data never recorded.

In a recursive CTE, what does the anchor query do?

  • It runs once to produce the starting row(s) the recursive step builds on
  • It runs again after every recursive step to reset the sequence
  • It defines the termination bound for the recursion

The anchor runs exactly once and seeds the CTE with its first row(s). The recursive step then runs repeatedly, each time selecting from the rows produced so far. Termination comes from the WHERE bound in the recursive step, not from the anchor.

Practice: Your Own Series

Write a recursive CTE named counter that returns the numbers 1 through 5, one per row, in ascending order, in a column named n. Remember the three parts: anchor, UNION ALL, and a recursive step with an explicit bound. Then hit Check my answer.

WITH RECURSIVE counter(n) AS (
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM counter WHERE n < 5
)
SELECT n FROM counter
ORDER BY n;

SQLite vs MySQL/PostgreSQL. PostgreSQL and MySQL 8.0+ require the RECURSIVE keyword. SQLite tolerates omitting it, but write it every time so your query travels.

What You Learned

  • A recursive CTE has three parts: an anchor query that runs once, UNION ALL, and a recursive step that selects from the CTE itself to build each next row.
  • The WHERE bound in the recursive step is what stops the recursion, and it is checked against the existing row before the next one is built, so watch for off-by-one bounds.
  • Every recursive query must carry an explicit termination bound; without one it runs until the engine, or your browser tab, gives out.
  • Recursive CTEs generate rows that exist in no table, like a number series or a calendar built with date(day, '+1 day').
  • Generated rows LEFT JOINed to real tables reveal the gaps in your data, like the 23 July days, every weekend among them, when the shop took no orders.

You can now:

  • Write a WITH RECURSIVE counter with a correct anchor, step, and bound.
  • Generate a date range and join it to real data to find the missing days.
  • Spot and fix a recursion that has no termination bound before it runs away.
You’ll learn to
  • Generate rows with anchor + recursive step
  • Always bound the recursion
Next: Set Operations: UNION, INTERSECT, EXCEPT →