learn-sql-with-phoebe / Builder session 6 of 10
Learn SQL with Phoebe · Builder track · Session 6 of 10

Subqueries and CTEs

You already write queries that answer one question. Tonight you learn to feed one query into another - a query inside a query - and then to name those steps so a complicated question reads top-to-bottom like a recipe instead of inside-out like a puzzle. Same Daybreak database, two moves that change how much SQL you can hold in your head.

🟠 Builder track Practitioners: analysts · DE · DS · PMs Runs in your browser · SQLite 45 min
0-3 · Welcome 3-24 · Subqueries & WITH 24-42 · Build-along 42-45 · Q&A
Part 0

Where this session fits

By now you can filter, aggregate, and join. But some questions need an answer to compute the answer - "which products cost more than average?" needs the average first, then the comparison. That is a subquery: a query nested inside another. And when you stack several such steps, a CTE (Common Table Expression, written with WITH) lets you name each step so the whole thing stays readable. Both run against the same six Daybreak tables you already know.

Live - presented in session Self-study - read after class ▶ Live SQL - editable & runnable Official sources covered
★ What you walk out with today A scalar subquery you can drop into any WHERE, the IN (SELECT ...) pattern for "matches a list from another table", and the WITH habit that turns a scary nested query into a clean staircase of named steps - the single biggest readability upgrade in SQL.
Part 1 · covers Mode Advanced "Writing Subqueries", SQLZoo "SELECT within SELECT"

Scalar subquery: a query inside a query 7 min live

A scalar subquery is a query that returns exactly one value - one number, one date, one name - which you then use anywhere a single value is allowed. The classic case: compare each row against an aggregate of the whole table, like "price above the average price". SQLite runs the inner query first, gets one number, then runs the outer query using it.

SELECT AVG(price) FROM products inner runs first one value: 18.6 ... WHERE price > 18.6 The inner query hands one number up to the outer query, which uses it like a typed-in constant. Scalar = one value. If the inner query could return many rows, use IN instead (Part 2).
🔍 Click to zoom - a scalar subquery: inner query first, one value, then the outer comparison
LiveProducts priced above the average4 min

You cannot write WHERE price > AVG(price) directly - an aggregate is not allowed in WHERE. So you compute the average in a subquery, wrapped in parentheses, and compare against that single value. Run it, then change > to < to flip to below-average.

SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products)
ORDER BY price DESC;
Why the parentheses matter The subquery must return a single value here, so it goes in parentheses and SQLite evaluates it once. If you accidentally write a subquery that returns many rows in a spot expecting one, you get an error - that is the engine protecting you.
Self-studySubqueries can go in SELECT too2 min read

A scalar subquery is legal anywhere a single value is: in WHERE, in SELECT, even in HAVING. Putting one in the SELECT list lets you show a per-row value next to a whole-table figure - handy for "this price vs the average" side by side.

SELECT name, price,
       (SELECT ROUND(AVG(price),2) FROM products) AS avg_price
FROM products
ORDER BY price DESC;

Every row shows the same avg_price - because the subquery is computed once over the whole table, not per row.

Part 2 · covers SQLZoo "SELECT within SELECT", Kaggle "As & With"

Subqueries with IN: matching a list 6 min live

The other everyday shape: a subquery that returns many values, which you match against with IN. Instead of "is this price above one number", it is "is this customer in the set of customers who ever had a refund". The inner query builds the list; the outer query keeps rows whose value appears in it.

LiveCustomers who have ever been refunded4 min

The inner query collects every customer_id that appears on a refunded order. The outer query then lists the customers whose id is IN that set. No join needed - a subquery reads cleanly when you only want to filter, not to pull columns from the other table.

SELECT name
FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders WHERE status='refunded');
Real world

The "has ever done X" question. Support and growth teams ask this constantly: customers who ever churned, ever opened a ticket, ever bought equipment. The IN (SELECT ...) pattern answers all of them with the same shape - build the id list in the subquery, filter the main table against it. Swap the inner table and condition, keep the skeleton.

Self-studyNOT IN for the opposite set2 min read

Flip IN to NOT IN and you get the complement - here, customers who have never been refunded. It is the fastest way to ask "who is missing from this list?" without a join.

SELECT name
FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM orders WHERE status='refunded')
ORDER BY name;

One caution for later warehouses: if the inner list can contain NULL, NOT IN behaves oddly. Daybreak's ids are never null, so it is safe here - but keep it in mind when you move to production data.

Part 3 · covers Mode Advanced "Writing Subqueries", Kaggle "As & With"

CTEs with WITH: naming your steps 8 min live

Subqueries are powerful, but stack a few and they nest inward - you read a big query from the deepest parentheses outward, which is exactly backwards from how you think. A CTE fixes this. WITH name AS (query) lets you name an intermediate result, then use that name like a table in the query below. The whole thing reads top-to-bottom.

Nested subquery innermost query Read from the deepest parentheses outward - inside-out, backwards. Named CTE steps WITH spend AS (...) , ranked AS (...) SELECT ... FROM ranked Each step named, read top-to-bottom, like a recipe. This is the upgrade. Same result either way. The CTE version is the one your teammate (and future you) can actually read.
🔍 Click to zoom - nested subquery read inside-out vs a named CTE staircase read top-to-bottom
LiveTop 5 customers by spend, with a CTE4 min

Here WITH spend AS (...) computes each customer's completed revenue once and names it spend. The query below then joins that named result to customers for readable names. Run it - you should see Noah Park on top around 232.

WITH spend AS (
  SELECT o.customer_id, SUM(oi.quantity*oi.unit_price) AS total
  FROM orders o
  JOIN order_items oi ON o.order_id = oi.order_id
  WHERE o.status='completed'
  GROUP BY o.customer_id
)
SELECT c.name, ROUND(s.total,2) AS total
FROM spend s
JOIN customers c ON c.customer_id = s.customer_id
ORDER BY s.total DESC
LIMIT 5;
Reading a CTE Read WITH spend AS (...) as "let spend mean the result of this query". Everything after can use spend as if it were a real table. You can chain several with commas - each can reference the ones above it.
Self-studyWhy CTEs beat repeating a subquery2 min read

Beyond readability, a CTE lets you reference the same intermediate result more than once without copy-pasting the subquery. Name it once, use it twice. It also makes debugging easy: comment out the final SELECT, run just the CTE by itself, and check the intermediate rows.

  • Readable: steps have names, so the query reads like prose.
  • Reusable: reference the named result multiple times, no duplication.
  • Debuggable: test each step in isolation before wiring them together.
Build-along

Rewrite a nested subquery as a clean CTE ★ 16 min · everyone builds

Same answer as Part 1 - products priced above the average - but let us feel the difference between the nested form and the CTE form. You will see the identical result, and you will read the second one far more easily. This is the habit that pays off every day you write SQL.

Start from the nested version: the average lives inside the WHERE as a subquery. It works, but the "what is the average" step is buried in the condition.

Pull that step out: write WITH avg_p AS (SELECT AVG(price) AS a FROM products) to give the average a name.

Reference the named step: the main query joins nothing - it just compares price to avg_p.a, read from a clearly named source.

Read both out loud. The CTE version says "let avg_p be the average price; now show products above it" - top-to-bottom, no backwards nesting.

WITH avg_p AS (
  SELECT AVG(price) AS a FROM products
)
SELECT p.name, p.price
FROM products p, avg_p
WHERE p.price > avg_p.a
ORDER BY p.price DESC;
Real world

The senior-analyst tell. When a reviewer opens a 40-line query, the first thing they judge is whether they can follow it. A wall of nested parentheses gets a "please refactor"; a stack of named CTE steps gets approved. Same result, very different reception - CTEs are how you write SQL that other people trust and reuse.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Official sources covered

This session teaches the subquery and CTE core of the major free SQL curricula, run on live Daybreak data instead of screenshots. Graded problem sets and certificates stay on the official sites (linked below). This page covers:

Mode SQL Tutorial - Advanced: Writing Subqueries in SQLParts 1-3 · scalar subqueries, IN, and the WITH refactor, run live
SQLZoo - SELECT within SELECTParts 1-2 · scalar and IN subqueries against a related table
Kaggle - Intro to SQL: As & WithPart 3 · naming intermediate results with CTEs; Kaggle's exercises stay on Kaggle
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What does a scalar subquery return?

A scalar subquery returns exactly one value, which you can drop anywhere a single value is allowed - like the right side of a WHERE comparison.

2 · Why reach for a CTE (WITH) instead of nesting subqueries?

CTEs give intermediate results names, so a complex query reads like a recipe from the top down rather than from the deepest parentheses outward.

3 · What does WHERE price > (SELECT AVG(price) FROM products) do?

The subquery computes the average once, then the outer query keeps every product whose price is greater than that single number.

Builder session 6 cheat sheet · pin this

SubqueryA query inside another, wrapped in parentheses. Inner runs first, outer uses its result.
Scalar subqueryReturns one value. Legal in WHERE, SELECT, HAVING. e.g. (SELECT AVG(price) FROM products).
IN (SELECT ...)Match against a list from another table. "has ever done X" questions.
NOT INThe complement - who is missing from the list. Watch NULLs in production data.
CTE / WITHWITH name AS (query) names an intermediate result you use below like a table.
Chain CTEsSeparate with commas; each can reference the ones above it. Reads top-to-bottom.
Debug a CTERun just the WITH block by itself to check intermediate rows before wiring the final SELECT.
Next upb7 window functions: per-group ranks and running totals without a subquery at all.