SQL exercises with solutions: recursive CTEs

SQL exercises with solutions: recursive CTEs

Updated on

A recursive CTE calls itself to generate a sequence or walk a hierarchy. Always add a stop condition. These 11 exercises range from level 7 to level 8; they use the syntax of SQLite, SpeedQL’s SQL engine.

Tip: A starting part, UNION ALL, then the part that calls itself with a WHERE that stops.

Read the “Recursive CTEs” card in the cheat sheet

Exercise 1 · level 7

Show each employee with their hierarchy level: 0 for the one with no manager, 1 for their direct reports, and so on (columns: name, level). Use a recursive CTE.

Table staff (10 rows)
idnamedepartmentsalarymanager_idhired
1AliceFinance52000NULL2015-03-01
2BobIT4100012018-06-15
3ClaireFinance3800012019-01-10
4DavidHR2900012020-09-01
5EmmaIT5000022017-11-20
6FaridIT4700022021-02-14
7GaelleHR3300042022-05-30
8HugoFinance4400032016-08-08
9IrisIT4700052023-01-09
10JulesFinance4400032024-03-18
Show the hint

Topics to use: WHERE (filters), JOIN, NULL, COALESCE, Recursive CTEs.

Query structure:

WITH RECURSIVE (, , ) AS (
  SELECT , , 
  FROM 
  WHERE  IS NULL
  UNION ALL SELECT , ,  + 
  FROM  
  JOIN  ON  = )
SELECT , 
FROM 
Show the solution
WITH RECURSIVE t(id, name, level) AS (
  SELECT id, name, 0
  FROM staff
  WHERE manager_id IS NULL
  UNION ALL SELECT s.id, s.name, t.level + 1
  FROM staff s
  JOIN t ON s.manager_id = t.id)
SELECT name, level
FROM t;

Expected result (10 rows):

namelevel
Alice0
Bob1
Claire1
David1
Emma2
Farid2
Hugo2
Jules2
Gaelle2
Iris3

Exercise 2 · level 7

For each year from 2012 to 2022, show the year and the number of movies released that year (0 if none). Use a recursive CTE to generate the years.

Table movies (10 rows)
idtitlegenreyeardurationratingdirector_id
1Night TrainThriller20151187.81
2Blue HarborDrama20181027.12
3Paper Moon CityComedy2012956.45
4Silent PeakDrama20201318.23
5Last SignalSci-Fi20191427.51
6Summer KeysComedy2016885.94
7Iron GardenSci-Fi202112583
8Dust and GoldWestern20141106.82
9The Quiet HourDrama2022977.45
10Deep CurrentThriller20171056.9NULL
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, Subqueries, Recursive CTEs.

Query structure:

WITH RECURSIVE () AS (
  SELECT 
  UNION ALL SELECT  + 
  FROM 
  WHERE  < )
SELECT , (SELECT COUNT(*) FROM  WHERE  = )
FROM 
Show the solution
WITH RECURSIVE y(n) AS (
  SELECT 2012
  UNION ALL SELECT n + 1
  FROM y
  WHERE n < 2022)
SELECT n, (SELECT COUNT(*) FROM movies WHERE year = n)
FROM y;

Expected result (11 rows):

n(SELECT COUNT(*) FROM movies WHERE year = n)
20121
20130
20141
20151
20161
20171
20181
20191
20201
20211
20221

Exercise 3 · level 7

For each day from 2025-03-01 to 2025-03-10, show the date and the number of loans started that day (0 if none). Use a recursive CTE to generate the dates.

Table loans (12 rows)
idbook_idmember_idloan_datereturn_date
1112025-01-052025-01-19
2322025-01-102025-02-02
3512025-02-012025-02-10
4732025-02-03NULL
5342025-02-152025-03-01
6222025-03-022025-03-30
7852025-03-05NULL
8132025-03-102025-03-18
9612025-03-202025-04-15
10352025-04-01NULL
11942025-04-052025-04-12
121022025-04-082025-04-20
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, Subqueries, Text and dates, Recursive CTEs.

Query structure:

WITH RECURSIVE () AS (
  SELECT 
  UNION ALL SELECT DATE(, )
  FROM 
  WHERE  < )
SELECT , (SELECT COUNT(*) FROM  WHERE  = )
FROM 
Show the solution
WITH RECURSIVE d(day) AS (
  SELECT '2025-03-01'
  UNION ALL SELECT date(day, '+1 day')
  FROM d
  WHERE day < '2025-03-10')
SELECT day, (SELECT COUNT(*) FROM loans WHERE loan_date = day)
FROM d;

Expected result (10 rows):

day(SELECT COUNT(*) FROM loans WHERE loan_date = day)
2025-03-010
2025-03-021
2025-03-030
2025-03-040
2025-03-051
2025-03-060
2025-03-070
2025-03-080
2025-03-090
2025-03-101

Exercise 4 · level 7

The league has 5 rounds: round 1 starts on 2025-08-02 and each round starts 7 days after the previous one. For each round, show its number, its start date and the number of goals scored that day or the next day (0 if none). Use a recursive CTE.

Table matches (10 rows)
idplayed_onhome_idaway_idhome_goalsaway_goals
12025-08-021221
22025-08-033400
32025-08-095113
42025-08-102322
52025-08-164531
62025-08-171310
72025-08-232401
82025-08-243523
92025-08-304111
102025-08-315202
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, Subqueries, NULL, COALESCE, Text and dates, Recursive CTEs.

Query structure:

WITH RECURSIVE (, ) AS (
  SELECT , 
  UNION ALL SELECT  + , DATE(, )
  FROM 
  WHERE  < )
SELECT , , COALESCE((SELECT SUM( + ) FROM  WHERE  BETWEEN  AND DATE(, )), )
FROM 
Show the solution
WITH RECURSIVE r(n, d) AS (
  SELECT 1, '2025-08-02'
  UNION ALL SELECT n + 1, date(d, '+7 day')
  FROM r
  WHERE n < 5)
SELECT n, d, COALESCE((SELECT SUM(home_goals + away_goals) FROM matches WHERE played_on BETWEEN d AND date(d, '+1 day')), 0)
FROM r;

Expected result (5 rows):

ndCOALESCE((SELECT SUM(home_goals + away_goals) FROM matches WHERE played_on BETWEEN d AND date(d, '+1 day')), 0)
12025-08-023
22025-08-098
32025-08-165
42025-08-236
52025-08-304

Exercise 5 · level 7

From CDG, find the airports you can reach by taking at most 2 flights (ignoring schedules): show the code of each airport other than CDG and the minimum number of flights to reach it. Use a recursive CTE.

Table flights (12 rows)
idairlineorigindestdepartsduration_minpriceseats_soldcapacity
1SkyJetCDGMAD2025-06-01 08:1012589150180
2AirNovaCDGLIS2025-06-01 11:40155120160170
3BlueWingLYSFCO2025-06-02 07:309575110150
4SkyJetMADCDG2025-06-02 18:2012095170180
5AirNovaBERCDG2025-06-03 09:05110105140160
6BlueWingNCEBER2025-06-03 13:5013014090150
7SkyJetCDGFCO2025-06-04 06:4513599175180
8AirNovaLISMAD2025-06-04 16:15756560120
9BlueWingFCOLYS2025-06-05 20:3010082130150
10SkyJetCDGBER2025-06-05 10:00105110120180
11AirNovaMADLIS2025-06-06 12:25807095120
12BlueWingLYSMAD2025-06-06 15:4011579100150
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, GROUP BY, JOIN, Recursive CTEs.

Query structure:

WITH RECURSIVE (, ) AS (
  SELECT , 
  UNION SELECT ,  + 
  FROM 
  JOIN   ON  = 
  WHERE  < )
SELECT , MIN()
FROM 
WHERE  <> 
GROUP BY 
Show the solution
WITH RECURSIVE r(code, n) AS (
  SELECT 'CDG', 0
  UNION SELECT f.dest, r.n + 1
  FROM r
  JOIN flights f ON f.origin = r.code
  WHERE r.n < 2)
SELECT code, MIN(n)
FROM r
WHERE code <> 'CDG'
GROUP BY code;

Expected result (5 rows):

codeMIN(n)
BER1
FCO1
LIS1
LYS2
MAD1

Exercise 6 · level 7

For each day from 2025-06-29 to 2025-07-05, show the day and the total rain recorded across all cities (0 if there is no reading that day). Use a recursive CTE to generate the days.

Table readings (15 rows)
idcitydaytemp_maxtemp_minrain_mm
1Paris2025-07-0124150
2Paris2025-07-0227170
3Paris2025-07-0322164.5
4Paris2025-07-04191412
5Paris2025-07-0523130
6Lyon2025-07-0126160
7Lyon2025-07-0229180
8Lyon2025-07-0331190
9Lyon2025-07-0424178.5
10Lyon2025-07-0522153
11Marseille2025-07-0130210
12Marseille2025-07-0232220
13Marseille2025-07-0333230
14Marseille2025-07-0429210
15Marseille2025-07-0528200
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, Subqueries, NULL, COALESCE, Text and dates, Recursive CTEs.

Query structure:

WITH RECURSIVE () AS (
  SELECT 
  UNION ALL SELECT DATE(, )
  FROM 
  WHERE  < )
SELECT , COALESCE((SELECT SUM() FROM   WHERE  = ), )
FROM 
Show the solution
WITH RECURSIVE d(day) AS (
  SELECT '2025-06-29'
  UNION ALL SELECT date(day, '+1 day')
  FROM d
  WHERE day < '2025-07-05')
SELECT day, COALESCE((SELECT SUM(rain_mm) FROM readings r WHERE r.day = d.day), 0)
FROM d;

Expected result (7 rows):

dayCOALESCE((SELECT SUM(rain_mm) FROM readings r WHERE r.day = d.day), 0)
2025-06-290
2025-06-300
2025-07-010
2025-07-020
2025-07-034.5
2025-07-0420.5
2025-07-053

Exercise 7 · level 7

For each night from 2025-07-01 to 2025-07-10, show the date and the number of occupied rooms (arrival on or before that day and departure after that day). Use a recursive CTE to generate the dates.

Table bookings (11 rows)
idroom_idguest_idcheck_incheck_out
1212025-07-012025-07-04
2522025-07-022025-07-05
3832025-07-032025-07-06
4342025-07-052025-07-12
5652025-07-062025-07-08
6122025-07-082025-07-10
7932025-07-102025-07-11
8752025-07-112025-07-15
9412025-07-142025-07-16
10642025-07-152025-07-19
11652025-07-162025-07-18
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, Subqueries, Text and dates, Recursive CTEs.

Query structure:

WITH RECURSIVE () AS (
  SELECT 
  UNION ALL SELECT DATE(, )
  FROM 
  WHERE  < )
SELECT , (SELECT COUNT(*) FROM   WHERE  <=  AND  > )
FROM 
Show the solution
WITH RECURSIVE d(day) AS (
  SELECT '2025-07-01'
  UNION ALL SELECT date(day, '+1 day')
  FROM d
  WHERE day < '2025-07-10')
SELECT day, (SELECT COUNT(*) FROM bookings b WHERE b.check_in <= d.day AND b.check_out > d.day)
FROM d;

Expected result (10 rows):

day(SELECT COUNT(*) FROM bookings b WHERE b.check_in <= d.day AND b.check_out > d.day)
2025-07-011
2025-07-022
2025-07-033
2025-07-042
2025-07-052
2025-07-062
2025-07-072
2025-07-082
2025-07-092
2025-07-102

Exercise 8 · level 7

For each month from 2025-01 to 2025-06 (format YYYY-MM), show the month and the number of orders placed (0 if none). Use a recursive CTE to generate the months.

Table orders (8 rows)
idcustomer_idorder_datestatus
112025-01-05shipped
222025-01-12shipped
312025-02-03paid
432025-02-10cancelled
542025-02-20shipped
652025-03-02shipped
722025-03-15paid
832025-03-28shipped
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, Subqueries, Text and dates, Recursive CTEs.

Query structure:

WITH RECURSIVE () AS (
  SELECT 
  UNION ALL SELECT STRFTIME(,  || , )
  FROM 
  WHERE  < )
SELECT , (SELECT COUNT(*) FROM   WHERE STRFTIME(, ) = )
FROM 
Show the solution
WITH RECURSIVE m(month) AS (
  SELECT '2025-01'
  UNION ALL SELECT strftime('%Y-%m', month || '-01', '+1 month')
  FROM m
  WHERE month < '2025-06')
SELECT month, (SELECT COUNT(*) FROM orders o WHERE strftime('%Y-%m', o.order_date) = m.month)
FROM m;

Expected result (6 rows):

month(SELECT COUNT(*) FROM orders o WHERE strftime('%Y-%m', o.order_date) = m.month)
2025-012
2025-023
2025-033
2025-040
2025-050
2025-060

Exercise 9 · level 7

For each month from 2025-02 to 2025-06 (format YYYY-MM), show the month and the hours of the tasks finished that month (0 if none). Use a recursive CTE to generate the months.

Table tasks (10 rows)
idproject_iddev_idtitlehoursstatusdone_on
111Landing page12done2025-03-10
213Data model20done2025-03-20
312Login8doingNULL
424ETL job16done2025-04-02
525CI pipeline6done2025-03-15
631Dashboard14todoNULL
733Report10done2025-04-25
842API18doingNULL
945Monitoring9todoNULL
1044Forecast22done2025-05-05
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, Subqueries, NULL, COALESCE, Text and dates, Recursive CTEs.

Query structure:

WITH RECURSIVE () AS (
  SELECT 
  UNION ALL SELECT STRFTIME(,  || , )
  FROM 
  WHERE  < )
SELECT , COALESCE((SELECT SUM() FROM   WHERE STRFTIME(, ) = ), )
FROM 
Show the solution
WITH RECURSIVE m(month) AS (
  SELECT '2025-02'
  UNION ALL SELECT strftime('%Y-%m', month || '-01', '+1 month')
  FROM m
  WHERE month < '2025-06')
SELECT month, COALESCE((SELECT SUM(hours) FROM tasks t WHERE strftime('%Y-%m', t.done_on) = m.month), 0)
FROM m;

Expected result (5 rows):

monthCOALESCE((SELECT SUM(hours) FROM tasks t WHERE strftime('%Y-%m', t.done_on) = m.month), 0)
2025-020
2025-0338
2025-0426
2025-0522
2025-060

Exercise 10 · level 7

For each day from 2025-01-01 to 2025-01-07, show the date and the sum of that day’s transactions, all accounts together (0 if there are none). Use a recursive CTE to generate the dates.

Table transactions (14 rows)
idaccount_idmade_onamountlabel
112025-01-022500salary
212025-01-05-60groceries
312025-01-12-800rent
422025-01-15500transfer
532025-01-031900salary
632025-01-20-120groceries
732025-02-01-950rent
842025-01-252100salary
942025-02-03-45restaurant
1012025-02-022600salary
1112025-02-06-75groceries
1252025-02-1030interest
1322025-02-15500transfer
1442025-02-18-600rent
Show the hint

Topics to use: WHERE (filters), COUNT, SUM, AVG, MIN, MAX, Subqueries, NULL, COALESCE, Text and dates, Recursive CTEs.

Query structure:

WITH RECURSIVE () AS (
  SELECT 
  UNION ALL SELECT DATE(, )
  FROM 
  WHERE  < )
SELECT , COALESCE((SELECT SUM() FROM   WHERE  = ), )
FROM 
Show the solution
WITH RECURSIVE d(day) AS (
  SELECT '2025-01-01'
  UNION ALL SELECT date(day, '+1 day')
  FROM d
  WHERE day < '2025-01-07')
SELECT day, COALESCE((SELECT SUM(amount) FROM transactions t WHERE t.made_on = d.day), 0)
FROM d;

Expected result (7 rows):

dayCOALESCE((SELECT SUM(amount) FROM transactions t WHERE t.made_on = d.day), 0)
2025-01-010
2025-01-022500
2025-01-031900
2025-01-040
2025-01-05-60
2025-01-060
2025-01-070

Exercise 11 · level 8

For each employee who has at least one report, show their name and the total number of their reports, direct and indirect. Use a recursive CTE.

Table staff (10 rows)
idnamedepartmentsalarymanager_idhired
1AliceFinance52000NULL2015-03-01
2BobIT4100012018-06-15
3ClaireFinance3800012019-01-10
4DavidHR2900012020-09-01
5EmmaIT5000022017-11-20
6FaridIT4700022021-02-14
7GaelleHR3300042022-05-30
8HugoFinance4400032016-08-08
9IrisIT4700052023-01-09
10JulesFinance4400032024-03-18
Show the hint

Topics to use: COUNT, SUM, AVG, MIN, MAX, GROUP BY, JOIN, HAVING, Recursive CTEs.

Query structure:

WITH RECURSIVE (, ) AS (
  SELECT , 
  FROM 
  UNION ALL SELECT , 
  FROM  
  JOIN  ON  = )
SELECT , COUNT(*) - 
FROM 
JOIN   ON  = 
GROUP BY 
HAVING COUNT(*) > 
Show the solution
WITH RECURSIVE d(root, id) AS (
  SELECT id, id
  FROM staff
  UNION ALL SELECT d.root, s.id
  FROM staff s
  JOIN d ON s.manager_id = d.id)
SELECT m.name, COUNT(*) - 1
FROM d
JOIN staff m ON m.id = d.root
GROUP BY d.root
HAVING COUNT(*) > 1;

Expected result (5 rows):

nameCOUNT(*) - 1
Alice9
Bob3
Claire2
David1
Emma1

Practice with automatic checking

In SpeedQL, you write your query and it is checked straight away, on these tables and then on a hidden dataset.

Practice on the 18 “Recursive CTEs” questions