Common Table Expressions (CTEs)
WITH clauses turn nested SQL into readable, named steps — the single biggest readability win in modern SQL.
What you'll learn
- The WITH name AS (…) syntax and what it gives you
- Chaining CTEs into named analytical steps
- Why CTEs beat subqueries for anything multi-step
- The 'CTEs aren't always inlined' performance gotcha
Before you start
A Common Table Expression is a named subquery written at the top of your statement with one keyword, WITH. Once defined, the rest of the query treats it like a table.
Why it matters: nested subqueries force you to write inside-out — the reader has to find the deepest parentheses, then walk outward. A CTE lets you write top-down, in the order you would describe the problem aloud. We will use these two tables (each order carries its revenue in amount):
| users id | name | country |
|---|---|---|
| 1 | Asha | IN |
| 2 | Bo | CN |
| 3 | Carlos | US |
| 4 | Diya | IN |
| orders | user_id | amount |
|---|---|---|
| 101 | 1 | 30 |
| 102 | 1 | 900 |
| 103 | 3 | 20 |
| 104 | 4 | 50 |
The same query, read top-down
WITH user_spend AS (
SELECT u.id, u.country, COALESCE(SUM(o.amount), 0) AS spend
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.country
)
SELECT country, AVG(spend) AS avg_spend
FROM user_spend
GROUP BY country
ORDER BY avg_spend DESC;
| country | avg_spend |
|---|---|
| IN | 490 |
| US | 20 |
| CN | 0 |
The CTE says, in plain order, “first compute each user’s spend, then average it per country.” Asha’s 930 and Diya’s 50 average to 490 for IN; Bo, who never ordered, is kept at 0 by the LEFT JOIN. That reads exactly how you would explain it to a colleague.
Chaining steps
Several CTEs can sit in one WITH, comma-separated, each referencing the ones before it. Here is a three-step pipeline — spend per user, then the top spender in each country:
WITH user_totals AS (
SELECT u.id, u.name, u.country, COALESCE(SUM(o.amount), 0) AS total_spend
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name, u.country
),
ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY country ORDER BY total_spend DESC) AS rn
FROM user_totals
)
SELECT country, name, total_spend
FROM ranked
WHERE rn = 1;
| country | name | total_spend |
|---|---|---|
| IN | Asha | 930 |
| US | Carlos | 20 |
| CN | Bo | 0 |
Each named step stands alone — you can swap the final SELECT for SELECT * FROM user_totals to inspect that stage. That debuggability is something nested subqueries simply cannot give you. The same shape builds the classic funnel: a signed_up CTE, a purchased CTE, a repeat_buyers CTE, each a stage, collapsed into one row of counts at the end (here: 4 signed up, 3 purchased, 1 repeat buyer).
| CTE | Subquery | |
|---|---|---|
| Reusable by name in the same query | Yes | No (must repeat) |
| Reads top-down | Yes | No (inside-out) |
| Can be recursive | Yes (WITH RECURSIVE) | No |
| Inspectable in isolation | Yes | Hard |
The reuse point is the real payoff: define user_spend once and reference it three times (country average, the global average, and their difference) — with subqueries you would paste the same five lines three times, and the next reader (probably you, in three months) would not thank you.
Practice
Quick check
Questions about this lesson
What is a CTE in SQL?
A CTE (Common Table Expression), written with the `WITH` keyword, is a named temporary result set you can reference within a single query. It makes complex queries readable by breaking them into named, sequential steps.
What's the difference between a CTE and a subquery?
Both are temporary, but a CTE is named up front with `WITH`, which makes it reusable within the query and easier to read, while a subquery is inline. A recursive CTE can also reference itself, which a subquery cannot.
Are CTEs faster than subqueries?
Usually not by themselves — most databases optimise them similarly, and a CTE's main benefit is readability. Some engines materialise a CTE, which can help or hurt, so check the query plan when performance matters.
Practice this in an interview
All questionsMaterialization means the database executes the CTE once, stores its result in a temporary structure, and re-reads that structure for each reference. It helps when the CTE is expensive and referenced many times, but hurts when the optimizer needs to push predicates through the CTE — which it cannot do across a materialization boundary.
CTEs improve readability and allow a named result to be referenced multiple times in one query, but most databases inline them during optimization, so they carry no inherent performance advantage over equivalent subqueries. Only databases that materialize CTEs by default — like older PostgreSQL versions — show a measurable difference.
Multiple CTEs are defined in a single WITH clause separated by commas; each CTE can reference any CTE defined before it in the list but not one defined after. The entire WITH block is logically evaluated before the final SELECT, and each CTE is visible throughout the rest of the query including inside other CTEs.
A recursive CTE has an anchor member that seeds the recursion and a recursive member that joins back to the CTE itself; the engine iterates until no new rows are produced. It is the standard SQL approach for querying trees and graphs such as org charts, bill-of-materials, and threaded comments.