Breaking a query into steps with WITH
Name each step of a hard question, then read the query top to bottom like a recipe.
In this lesson 8 sections
Lesson 23 put queries inside other queries. That works, but nested parentheses get hard to read fast. A CTE (common table expression) lifts each step out to the top, gives it a name, and lets the main query use that name like a table.
The shape
WITH rep_totals AS (
SELECT owner_id, SUM(amount) AS pipeline
FROM opportunities
WHERE closed_at IS NULL
GROUP BY owner_id
)
SELECT AVG(pipeline) AS avg_rep_pipeline
FROM rep_totals;
That’s lesson 23’s per-rep average, $206,500, rewritten. Compare the two: same answer, but the CTE version reads top to bottom. First build rep_totals, then average it.
| Part | What it does |
|---|---|
WITH |
Starts the list of steps. Comes before the main SELECT. |
rep_totals AS ( ... ) |
One named step. Any SELECT goes inside. |
The main SELECT |
Uses the step’s name in FROM, like a table |
A CTE only exists while its query runs. It isn’t saved anywhere.
Several steps
Separate steps with a comma, and write WITH only once. A later step can use an earlier one:
WITH won AS (
SELECT owner_id, SUM(amount) AS won_amount
FROM opportunities
WHERE is_won = 1
GROUP BY owner_id
),
open_pipeline AS (
SELECT owner_id, SUM(amount) AS open_amount
FROM opportunities
WHERE closed_at IS NULL
GROUP BY owner_id
)
SELECT u.name,
COALESCE(w.won_amount, 0) AS won,
COALESCE(p.open_amount, 0) AS still_open
FROM users AS u
LEFT JOIN won AS w ON w.owner_id = u.user_id
LEFT JOIN open_pipeline AS p ON p.owner_id = u.user_id
ORDER BY u.name;
| name | won | still_open |
|---|---|---|
| Ana Ruiz | 235000 | 118500 |
| Ben Carter | 314000 | 285000 |
| Chloe Park | 159000 | 402000 |
| Dev Patel | 120000 | 88000 |
| Erin Walsh | 0 | 0 |
Each step aggregates on its own, so the final join is one row per rep on each side. That’s the safe way to combine two different totals, and it avoids the row-multiplying fan-out trap covered in lesson 28.
Using a step twice
A step can be used more than once. Here rep_pipeline feeds the main query and the average it’s compared to:
WITH rep_pipeline AS (
SELECT owner_id, SUM(amount) AS pipeline
FROM opportunities
WHERE closed_at IS NULL
GROUP BY owner_id
)
SELECT owner_id, pipeline
FROM rep_pipeline
WHERE pipeline > (SELECT AVG(pipeline) FROM rep_pipeline);
Without a CTE, you’d write that per-rep GROUP BY twice and have to keep both copies in sync.
Building it one step at a time
CTEs make debugging easy. Write the first step, then test it on its own by selecting straight from it:
WITH rep_pipeline AS (
SELECT owner_id, SUM(amount) AS pipeline
FROM opportunities
WHERE closed_at IS NULL
GROUP BY owner_id
)
SELECT * FROM rep_pipeline; -- check this step before adding the next
Once it looks right, replace the last line with the real query. When a number in the final result looks wrong, point that last SELECT at each step in turn until you find where it went wrong.
Subquery or CTE?
| Use a subquery when | Use a CTE when |
|---|---|
| It’s a single value, like an average to compare against | There are two or more steps |
| It’s short and used once | A step is used more than once |
| You’ll want to test steps one at a time |
They usually run the same. CTEs are about readability, not speed.
Common mistakes
| Mistake | Fix |
|---|---|
WITH before each step |
One WITH, then steps separated by commas |
| A comma after the last step | No comma between the last ) and the main SELECT |
A semicolon before the main SELECT |
The CTE and the main query are one statement |
| Using a step before it’s defined | Steps can only use steps above them |
Dialect notes
| Database | Notes |
|---|---|
| SQL Server | If the statement before WITH doesn’t end in ;, you get a confusing syntax error. That’s why you’ll see ;WITH in SQL Server code. |
| PostgreSQL | Supports CTEs that change data (WITH ... DELETE). Before version 12, every CTE was calculated in full first, which could be slower than a subquery. |
| MySQL | CTEs need version 8.0 or later. |
Snowflake notes
CTEs are standard in Snowflake, and they’re the backbone of dbt: a dbt model is usually a chain of CTEs (an import step per source table, a few logic steps, then a final SELECT). Snowflake decides how to run each CTE, so splitting a query into steps doesn’t slow it down.
More in the field guide: CTE (WITH) and the WITH breakdown. Exercise I5 is the Try it below, with an extra twist.
Try it
Which reps have more open pipeline than the average rep? Leave out deals with no owner. Return each rep’s name and open pipeline, largest first.
Write it in the sandbox below, then press Check answer.
SQL sandbox
Run queries against the sample CRM tables (accounts, opportunities,users, contacts and opportunity_history), right in your browser. Nothing you run leaves your computer.