SQL fundamentals

SQL CTE: the WITH clause, explained with real queries

A CTE turns a query you have to read inside-out into a query you read top-to-bottom. Below: the syntax, multiple CTEs, recursion, the honest performance story, and the mistakes reviewers catch in production code.

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

ToolLives forReach for it when
CTEOne statementYou want named, readable steps in a single query
SubqueryOne statementIt's a one-liner and naming it adds nothing
Temp tableThe sessionThe same result is reused by several statements, or you need an index on it
ViewForeverThe 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