EduFlow
SQL for Analysts
0%

Section 3 · Analytical SQL

Subqueries, CTEs and Recursive CTEs

18 min3 min readSQL exercise

Lesson at a glance

Use scalar, derived-table and correlated subqueries where they fit, restructure long queries into named CTE steps, and walk hierarchies of any depth with a recursive CTE.

  1. 1Three kinds of subquery
  2. 2The same analysis as CTEs
  3. 3Recursive CTEs: walking a hierarchy

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Karim asks a question with a hidden second question inside it: “Which customers spent more than the average customer in 2025?” To answer it you need the average first, which itself needs per-customer revenue. That’s three steps: revenue per customer, the average of those, then the comparison. SQL gives you two ways to nest steps, subqueries and CTEs, and the choice decides whether the next analyst can read your work.

Three kinds of subquery

You’ve already used two of them. A derived table is a subquery in FROM that acts like a table (the per-order revenue that fixed the fan-out). A correlated subquery refers to the outer row and runs per row (the NOT EXISTS anti-join). The third is the scalar subquery: a query that returns exactly one value, usable anywhere a value is.

Here’s Karim’s question written with nesting only:

SQL
SELECT COUNT(*) AS above_average_customers
FROM (
  SELECT o.customer_id, SUM(oi.quantity * oi.unit_price * (1 - oi.discount)) AS revenue
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
    AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'
  GROUP BY o.customer_id
) AS r
WHERE r.revenue > (
  SELECT AVG(revenue) FROM (
    SELECT SUM(oi.quantity * oi.unit_price * (1 - oi.discount)) AS revenue
    FROM orders AS o
    JOIN order_items AS oi ON oi.order_id = o.order_id
    WHERE o.status <> 'cancelled'
      AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'
    GROUP BY o.customer_id
  )
);

It works: 140 customers. It’s also hard to read, and the revenue logic is written twice. When someone changes the definition of revenue next quarter, they’ll update one copy and not the other.

The same analysis as CTEs

A common table expression names a query with WITH name AS (...), and later steps refer to it like a table:

SQL
WITH revenue_2025 AS (
  -- grain: one row per customer who bought in 2025
  SELECT o.customer_id,
         SUM(oi.quantity * oi.unit_price * (1 - oi.discount)) AS revenue
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.order_id
  WHERE o.status <> 'cancelled'
    AND o.order_date >= '2025-01-01' AND o.order_date < '2026-01-01'
  GROUP BY o.customer_id
),
benchmark AS (
  SELECT AVG(revenue) AS avg_revenue FROM revenue_2025
)
SELECT
  COUNT(*) AS buyers,
  SUM(r.revenue > b.avg_revenue) AS above_average,
  ROUND(b.avg_revenue, 2) AS avg_revenue
FROM revenue_2025 AS r
CROSS JOIN benchmark AS b;

Now the analysis reads like the sentence it answers: revenue per customer, the benchmark, the comparison. Revenue is defined once. And while building it you can replace the final SELECT with SELECT * FROM revenue_2025 LIMIT 5 to inspect any step, which is the single best debugging habit for long SQL. CROSS JOIN is safe here because benchmark has exactly one row. In SQLite a comparison is 1 or 0, so SUM(condition) counts TRUE rows; in PostgreSQL you’d write COUNT(*) FILTER (WHERE ...).

So 140 of 418 buyers sit above the 781.84 average. That’s typical of revenue data: a minority of large customers pulls the mean up, which is why you’ll often report the median beside it.

Recursive CTEs: walking a hierarchy

Rania’s reorganisation request comes back: “List everyone in Peter Weber’s engineering organisation, at any level, with how far below him they sit.” A self-join gets you one level. Peter’s org has two, and another department might have five. A recursive CTE walks as deep as the data goes:

SQL
WITH RECURSIVE org AS (
  -- anchor: where the walk starts
  SELECT employee_id, first_name, title, 0 AS depth, first_name AS path
  FROM employees
  WHERE employee_id = 4
  UNION ALL
  -- recursive step: people who report to anyone found so far
  SELECT e.employee_id, e.first_name, e.title, org.depth + 1,
         org.path || ' > ' || e.first_name
  FROM employees AS e
  JOIN org ON e.manager_id = org.employee_id
)
SELECT depth, path, title
FROM org
ORDER BY path;

The structure is always the same. The anchor query runs once and produces the starting rows. The recursive query joins employees to org itself, but on each iteration it only sees the rows added by the previous iteration. Each pass adds the next level down, and the walk ends when an iteration finds nobody.

Iteration 0 is the anchor, Peter. Iteration 1 adds his reports Felix and Ivy. Iteration 2 adds Felix's reports Salma, Ryan, Mona and Daniel. Iteration 3 finds no one, so the recursion stops. iteration 0 iteration 1 iteration 2 iteration 3 Peter Felix Ivy Salma Ryan Mona Daniel no new rows, so the recursion stops
Each iteration of the recursive CTE adds the next level of the org chart

depth and path are carried through the recursion: each new row computes its values from the parent row it joined to. Sorting by path prints the result as an indented tree. The same pattern rolls costs up a category tree, follows referral chains, or expands a bill of materials.

With CTEs you can name every step. The next lesson adds a tool that lets each row see its neighbours without collapsing anything: window functions.

Key takeaways

  • A scalar subquery returns one value and can sit anywhere a value can, such as a comparison against an overall average.
  • A CTE (WITH name AS …) names an intermediate result so a long analysis reads top to bottom and each step can be checked on its own.
  • A recursive CTE has an anchor query, UNION ALL, and a recursive query that joins back to the CTE; it stops when an iteration adds no rows.
  • Carry a depth counter (and a path if useful) through the recursion, and cap the depth when the data might contain a cycle.

Further reading

Check your understanding

4 questions

Question 1 of 4

Which is a scalar subquery used correctly?

Question 2 of 4

What’s the main reason analysts restructure a nested query into CTEs?

Question 3 of 4

A recursive CTE starts from the CEO and joins employees e ON e.manager_id = org.employee_id. When does it stop?

Question 4 of 4

Why add a condition like WHERE org.depth < 20 to a recursive query over customers.referred_by?

Practice

SQL exercise

Mark's whole organisation

Mark Evans (VP Operations, employee_id 2) needs his full organisation for headcount planning: everyone who reports to him directly or indirectly, not including Mark himself.

Return employee_id, first_name and depth (1 for his direct reports, 2 for their reports, and so on), sorted by depth and then employee_id. Order matters.

The starter’s anchor is already right but it stops at direct reports. Add the recursive step.

Tables
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Everyone below Mark Evans (employee 2), at any depth
WITH RECURSIVE org AS (
  SELECT employee_id, first_name, 1 AS depth
  FROM employees
  WHERE manager_id = 2
)
SELECT employee_id, first_name, depth
FROM org
ORDER BY depth, employee_id;Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.