CTE basics: naming a subquery
A WITH clause names a subquery so a tangled, nested query becomes a top-down pipeline of named steps — and each step is referenceable by every step that follows it.
You inherit a report query that nests four levels deep: a subquery inside a subquery inside a FROM, with the innermost one repeated twice because two outer branches both need it. Nobody can read it, nobody dares change it, and the on-call engineer just pasted it into a ticket with the note “do not touch.” That query does real work — but its shape is the bug. A WITH clause would flatten the whole thing into five named steps you could read top to bottom in ten seconds.
A CTE is a named subquery
When you name the messy piece of a query, the whole thing suddenly becomes reviewable — and that’s exactly what a CTE does. A common table expression (CTE) is a temporary, named result set defined in a WITH clause that exists only for the single statement attached to it. Syntactically it is just a subquery you gave a name. The payoff is structural: instead of nesting logic inward, you lay it out as a sequence of named steps, then the final query reads from them.
Compare the same intent — “managers whose team placed more than 100 orders last month” — written two ways against employees(id, manager_id, name) and an orders table:
-- Nested: read inside-out, and the inner aggregate is buried
SELECT e.name, t.order_count
FROM employees e
JOIN (
SELECT o.handled_by, count(*) AS order_count
FROM orders o
WHERE o.created_at >= date_trunc('month', now()) - interval '1 month'
AND o.created_at < date_trunc('month', now())
GROUP BY o.handled_by
) t ON t.handled_by = e.id
WHERE t.order_count > 100;
-- Same query, top-down pipeline
WITH last_month AS (
SELECT o.handled_by, count(*) AS order_count
FROM orders o
WHERE o.created_at >= date_trunc('month', now()) - interval '1 month'
AND o.created_at < date_trunc('month', now())
GROUP BY o.handled_by
)
SELECT e.name, lm.order_count
FROM last_month lm
JOIN employees e ON e.id = lm.handled_by
WHERE lm.order_count > 100;The two queries are logically the same. The second one names the messy part (last_month), so the final SELECT reads like a sentence. You debug a CTE by running it on its own — paste just the inner SELECT into a session and inspect its rows — which is far harder with a deeply nested derived table buried in a FROM.
Chaining: each CTE sees the ones before it
You can define several CTEs in one WITH, separated by commas. Every CTE can reference any CTE defined earlier in the same WITH clause, so you build a pipeline where step three reads from steps one and two. This is the real reason seniors reach for CTEs on analytics work: the query becomes a readable chain of transformations instead of a pyramid.
WITH recent_orders AS (
SELECT handled_by, amount
FROM orders
WHERE created_at >= now() - interval '30 days'
),
per_employee AS ( -- reads from recent_orders
SELECT handled_by, sum(amount) AS revenue, count(*) AS n
FROM recent_orders
GROUP BY handled_by
),
ranked AS ( -- reads from per_employee
SELECT handled_by, revenue, n,
revenue / NULLIF(n, 0) AS avg_ticket
FROM per_employee
WHERE n >= 10
)
SELECT e.name, r.revenue, r.avg_ticket
FROM ranked r
JOIN employees e ON e.id = r.handled_by
ORDER BY r.revenue DESC;The direction matters. A CTE cannot reference a CTE declared after it — that would be a forward reference, and Postgres rejects it — with one special exception: a CTE referencing itself, which is recursion, the subject of lesson 04. For now, order your CTEs so each one only depends on earlier ones.
What CTEs buy you, and what they don’t
The win is readability and reuse-within-a-statement: name a result once, reference it by name several times, and you never duplicate the same subquery text. CTEs do not persist — the name vanishes the instant the statement finishes. If you need a reusable named query across many statements, that is a VIEW, which lesson 02 contrasts with CTEs. And historically a CTE was an optimization fence that changed how Postgres ran your query; that behavior changed in Postgres 12, and lesson 03 is entirely about it. For this lesson, treat a CTE as a pure readability tool: same result, clearer shape.
▸Why this works
Why not just use more subqueries? Because reference and reuse get awkward fast. If two parts of your query both need “orders in the last 30 days,” a nested subquery forces you to either repeat the text — drift risk, since someone edits one copy — or contort the query so both branches share one derived table. A CTE solves it cleanly: declare recent_orders once, reference it from per_employee, from a cancellations branch, and anywhere else. The name is the abstraction. That’s the same instinct as extracting a function in application code — you’re not changing behavior, you’re naming an intermediate so the next reader (often future-you) sees intent instead of mechanics.
In a single WITH clause with CTEs a, b, c declared in that order, which references are legal?
A teammate says 'let's keep this report's CTE so other queries can reuse it tomorrow.' What's wrong with that plan?
Order the steps to refactor a nested report into a readable WITH pipeline:
- 1 Find the innermost subquery and give it a descriptive name in a WITH clause
- 2 Move the next layer out into its own CTE that reads from the first by name
- 3 Repeat outward until every nesting level is a named, top-down step
- 4 Rewrite the final SELECT to read from the last CTE by name
- 5 Run each CTE alone to confirm the refactor preserved the result
- 01What is a CTE, and how long does its name live?
- 02Within one WITH clause, which CTEs can a given CTE reference?
- 03When should you use a VIEW instead of a CTE?
A common table expression is nothing more than a subquery you named with a WITH clause — but that naming changes the shape of a query from an inside-out pyramid into a top-down pipeline you can read in seconds. Declare several CTEs separated by commas and each one can reference any CTE declared earlier, so step three reads from steps one and two; reference flows only forward in declaration order, and a non-recursive CTE can never look ahead. The benefit is readability plus within-statement reuse: name an intermediate once, reference it several times, debug each step in isolation. What a CTE is not is persistent — its name dies with the statement, so cross-statement reuse belongs to a view (lesson 02). And the question of how the planner runs a CTE — inlined or materialized — changed in Postgres 12 and is the whole story of lesson 03. Here, the lesson is the simplest and most durable one: same result, clearer shape. Now when you see a deeply nested report query land in your PR, you’ll reach for WITH first — name the innermost knot, flatten outward, and leave something the next on-call can actually read.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.