SQL CTE: the WITH clause, explained with real queries
The syntax in 6 lines
WITH recent_orders AS (
SELECT order_id, customer_id, amount, ordered_at
FROM orders
WHERE ordered_at >= current_date - INTERVAL '30 days'
)
SELECT customer_id, COUNT(*) AS orders, SUM(amount) AS revenue
FROM recent_orders
GROUP BY customer_id;recent_orders exists only while this statement runs. Nothing is created on disk, no cleanup, no permissions to grant.
Multiple CTEs: one statement, readable steps
This is where CTEs earn their keep — each step is named, and each can use the previous one.
WITH paid AS (
SELECT * FROM orders WHERE status = 'paid'
),
per_customer AS (
SELECT customer_id,
SUM(amount) AS revenue,
COUNT(*) AS orders,
MIN(ordered_at) AS first_order
FROM paid
GROUP BY customer_id
),
ranked AS (
SELECT *,
NTILE(4) OVER (ORDER BY revenue DESC) AS revenue_quartile
FROM per_customer
)
SELECT c.name, r.revenue, r.orders, r.revenue_quartile
FROM ranked r
JOIN customers c ON c.id = r.customer_id
WHERE r.revenue_quartile = 1
ORDER BY r.revenue DESC;Try rewriting that with nested subqueries and you will understand why analytics engineers write almost everything this way — it is also exactly how a dbt model is structured.
Recursive CTE: walking a hierarchy
An org chart with each employee's depth below the CEO:
WITH RECURSIVE org AS (
-- anchor: the top of the tree
SELECT id, name, manager_id, 1 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- recursive member: everyone reporting to a row we already found
SELECT e.id, e.name, e.manager_id, o.level + 1
FROM employees e
JOIN org o ON e.manager_id = o.id
WHERE o.level < 20 -- always bound the recursion
)
SELECT level, name FROM org ORDER BY level, name;The level < 20 guard is not optional in production. A cycle in the data (A manages B, B manages A) turns an unbounded recursive CTE into a query that never finishes.
Same pattern, different use — generating a gapless date spine for reporting:
WITH RECURSIVE dates AS (
SELECT DATE '2026-01-01' AS d
UNION ALL
SELECT d + 1 FROM dates WHERE d < DATE '2026-12-31'
)
SELECT d FROM dates;CTE vs subquery vs temp table vs view
| Tool | Lives for | Reach for it when |
|---|---|---|
| CTE | One statement | You want named, readable steps in a single query |
| Subquery | One statement | It's a one-liner and naming it adds nothing |
| Temp table | The session | The same result is reused by several statements, or you need an index on it |
| View | Forever | The logic is shared across many queries and users |
The performance truth (and the one trap)
Modern engines inline CTEs, so the plan is normally the same as the equivalent subquery. The trap is referencing an expensive CTE several times: some engines re-evaluate it each time. When that happens, materialize it on purpose.
-- PostgreSQL: force one evaluation, reuse the result
WITH heavy AS MATERIALIZED (
SELECT customer_id, SUM(amount) AS revenue
FROM orders GROUP BY customer_id
)
SELECT * FROM heavy WHERE revenue > 1000
UNION ALL
SELECT * FROM heavy WHERE revenue < 10;Always confirm with EXPLAIN ANALYZE instead of guessing — that habit is what separates a senior from someone who "heard CTEs are slow".
Interview-grade answer
"A CTE is a named temporary result set scoped to one statement. I use them to break a transformation into readable steps and to express recursion over hierarchies. They are usually inlined, so I don't treat them as an optimization — if a heavy CTE is referenced multiple times I materialize it deliberately and verify with EXPLAIN." Pair that with window functions and you can solve most SQL rounds. More practice in our SQL interview questions.
FAQ
- What is a CTE in SQL?
- A CTE (Common Table Expression) is a named temporary result set defined with the WITH keyword that exists only for the duration of a single statement. You reference it like a table in the SELECT, INSERT, UPDATE or DELETE that follows.
- What is the difference between a CTE and a subquery?
- Functionally they are close, but a CTE is named, can be referenced more than once in the same statement, and reads top-to-bottom instead of inside-out. Subqueries nest and get unreadable fast; CTEs let you name each step of the logic.
- Is a CTE faster than a subquery?
- Usually the plan is identical — most engines inline the CTE. PostgreSQL 12+ inlines by default unless you write MATERIALIZED. Only in engines that always materialize (older PostgreSQL, some MySQL cases) can there be a real difference. Choose CTEs for readability, not speed.
- Can you have multiple CTEs in one query?
- Yes. Write WITH once, then comma-separate the definitions. A later CTE can reference any earlier CTE, which is how you build a readable multi-step pipeline in a single statement.
- What is a recursive CTE used for?
- Hierarchies and graphs: org charts, category trees, bill of materials, date spines, and shortest-path style walks. It has an anchor member, UNION ALL, and a recursive member that references the CTE itself.
- Do CTEs work in MySQL, PostgreSQL, Snowflake and BigQuery?
- Yes — MySQL 8.0+, PostgreSQL 8.4+, SQL Server 2005+, Snowflake, BigQuery, Databricks and DuckDB all support WITH, and all of them support RECURSIVE except BigQuery before 2023 (now supported too).
Ready to become a member?
7 days free. Then less than a coffee per month — cancel anytime.
Become a member — 7 days free- No credit card for the trial
- Cancel anytime
- 300+ exercises
- 14 full courses