CTEs and subqueries
Every question so far has fitted in one step. Real ones often do not. "Which products beat the average" needs the average first. "How did each month compare to the last" needs the monthly totals first. Both are two-step questions, and this lesson is about giving the first step a name so the second step can read it.
You have two tools for that, and one of them is much easier to read than the other.
WITH gives a result a name
A common table expression, which everyone calls a CTE, is a named query that exists for the duration of the statement:
WITH monthly AS (
SELECT date_trunc('month', date) AS month, SUM(revenue) AS total
FROM csv
GROUP BY month
)
SELECT *
FROM monthly
ORDER BY month
Inside the brackets is a perfectly ordinary query, and it is the monthly summary from lesson 10.
Below the brackets, monthly
behaves exactly like a table. You can filter it, join it, group it again, or feed it to a window
function.
That last one is the reason CTEs matter for reporting. Window functions cannot be nested inside aggregates, so month-over-month growth genuinely cannot be written in a single flat query. You aggregate to months in a CTE, then LAG across the CTE. Two steps, in the order you would explain them to a colleague.
Several CTEs
Separate them with commas, and each may read the ones above it:
WITH by_region AS ( SELECT region, SUM(revenue) AS total FROM csv GROUP BY region ), overall AS ( SELECT SUM(revenue) AS grand FROM csv ) SELECT r.region, ROUND(100 * r.total / o.grand, 2) AS pct_of_total FROM by_region r, overall o ORDER BY pct_of_total DESC
Note the comma between the two files in the FROM clause. That is a cross join, and it is safe
here precisely because overall
is a single row: four regions times one row is four rows. Against anything with more than one
row, a cross join multiplies, and it is one of the few ways to accidentally turn a small query
into a very slow one.
A query with four or five well-named CTEs reads like a paragraph. A query with four levels of nested subqueries reads like a puzzle. When you inherit the second kind, converting it to the first, one level at a time, is usually the fastest way to understand it, and each step is mechanical enough to be safe.
Scalar subqueries
A parenthesised query returning exactly one row and one column is a value, and can go wherever a value can:
SELECT date, product, revenue FROM csv WHERE revenue > (SELECT AVG(revenue) FROM csv)
The inner query computes one number, the outer compares every row against it. This cannot be written with a plain WHERE, because you cannot use an aggregate of the whole table as a row-level condition. If the subquery returns more than one row you get an error; if it returns none you get NULL, and the comparison quietly matches nothing.
A subquery can also produce a whole table and sit in the FROM clause, which is what people did
before CTEs existed. The pattern still appears everywhere:
FROM (SELECT ...) AS t. It works
fine. A CTE says the same thing with the definition at the top instead of buried in the middle,
which is the only reason I prefer it.
IN with a subquery
A subquery returning one column and many rows works as the list for
IN:
SELECT * FROM csv WHERE product IN ( SELECT product FROM csv GROUP BY product HAVING SUM(units) > 200 )
Find the products that clear a threshold, then return every row belonging to them. This is a genuinely two-pass question and the shape is worth memorising: an aggregate in the inner query chooses the keys, the outer query fetches the detail.
The warning from lesson 4 comes due here. If the inner query can return a NULL, then
NOT IN returns nothing at all,
for every row, silently. Use
NOT EXISTS or a LEFT JOIN
anti-join instead, both of which behave sensibly with nulls. Plain
IN is unaffected.
Does it cost anything?
Almost never. A CTE is not a temporary table you are paying to build; in DuckDB, Postgres 12 and later, and every modern warehouse, the planner folds it into the surrounding query and optimises the whole thing together. Older Postgres treated a CTE as an optimisation fence, which is where the folklore about CTEs being slow comes from. On this engine, write whichever form is clearest.
One genuine exception: if a CTE is referenced several times and is expensive, some engines
recompute it each time. DuckDB and Postgres both offer
MATERIALIZED to force it to be
computed once. You will know when you need it, because the query will be slow in a way that
tracks the number of references.
Exercises
1. Products past a threshold
Using a CTE that totals revenue per product as total, return the products whose total is over twenty thousand. Two columns: product and total.
Hint
The CTE does the grouping; the outer query only filters it.
2. Better than average
Return date, product and revenue for every row whose revenue is above the average revenue of the whole file.
Hint
A scalar subquery on the right of the greater-than sign.
3. Every row of the popular products
Return every column of every row whose product sold more than two hundred units in total across the file.
Hint
IN with a subquery that groups and uses HAVING. Only one product clears the bar.
4. Each region's share
Return region and its percentage of total revenue as pct_of_total, rounded to two decimals, largest share first. Order is checked, and the four values should add up to a hundred.
Hint
Two CTEs, one per grouping level, combined in the final SELECT.
What to look up next
Look up recursive CTEs, written
WITH RECURSIVE. They let one
query walk a hierarchy, such as an org chart or a bill of materials, to any depth. It is the one
genuinely mind-bending corner of SQL and it is worth an afternoon.