What Is a CTE?
A Common Table Expression (CTE) is a named, temporary result set defined at the top of a query using the WITH keyword. The result set is scoped to the single statement that follows. CTEs were standardized in SQL:1999 and are supported by every major analytical database.
The motivating problem is readability. Consider computing, for each product category, the top-3 products by revenue. One approach nests three levels of subqueries:
SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
FROM (
SELECT category, product_id, SUM(amount) AS revenue
FROM orders
GROUP BY category, product_id
) agg
) ranked
WHERE rn <= 3;The same logic with a CTE is immediately legible:
WITH revenue_by_product AS (
SELECT category, product_id, SUM(amount) AS revenue
FROM orders
GROUP BY category, product_id
),
ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
FROM revenue_by_product
)
SELECT *
FROM ranked
WHERE rn <= 3;Each CTE block produces a named result that subsequent blocks — and the final query — can reference. The chain reads like a recipe: first we compute revenue, then we rank, then we filter.
Syntax and Scoping Rules
The full grammar is:
WITH
cte1 AS (SELECT ...),
cte2 AS (SELECT ... FROM cte1 ...),
...
FINAL_QUERY;Key rules:
- A CTE can reference any CTE defined before it in the same
WITHclause. - CTEs are not persistent; they exist only for the duration of the statement.
- In PostgreSQL and DuckDB, non-recursive CTEs are inlined by default — the optimizer folds them back into the main query for planning. To force materialization (useful when a CTE is expensive and referenced twice), add the
MATERIALIZEDkeyword:WITH expensive AS MATERIALIZED (...). BigQuery always materializes CTEs. - CTEs can wrap
INSERT,UPDATE, orDELETEstatements (writable CTEs), which enables multi-step data transformations in a single statement.
Why CTEs Matter for Analytical Queries
Analytical queries frequently need to compute intermediate aggregates, then filter or rank those aggregates, and sometimes join them back to raw data. Nested subqueries force you to read inside-out. CTEs let you write top-to-bottom, matching the order in which you reason about the problem.
A second practical benefit: debugging. You can run each CTE block independently (just copy it into a standalone SELECT) to verify intermediate results before composing them.
A third benefit is deduplication of logic. If the same subquery appears in multiple places in a complex JOIN, extracting it into a CTE avoids repeating code and ensures consistent results.
Example: Multi-Step Customer Segmentation
WITH
-- Step 1: compute lifetime value per customer
customer_ltv AS (
SELECT customer_id,
MIN(order_date) AS first_order,
MAX(order_date) AS last_order,
COUNT(*) AS order_count,
SUM(amount) AS ltv
FROM orders
GROUP BY customer_id
),
-- Step 2: segment customers
segmented AS (
SELECT *,
CASE
WHEN ltv >= 1000 THEN 'high'
WHEN ltv >= 250 THEN 'medium'
ELSE 'low'
END AS segment
FROM customer_ltv
),
-- Step 3: summarize segment distribution
summary AS (
SELECT segment,
COUNT(*) AS customers,
AVG(ltv) AS avg_ltv,
AVG(order_count) AS avg_orders
FROM segmented
GROUP BY segment
)
SELECT * FROM summary
ORDER BY avg_ltv DESC;Each CTE captures one conceptual step. Any step can be inspected independently, and the final query is four lines long.
Common Pitfalls
- Assuming materialization: in PostgreSQL, referencing a non-recursive CTE twice does not guarantee the optimizer computes it once. Use
MATERIALIZEDwhen the CTE is expensive and referenced multiple times. - Circular references: you cannot have
cte<em>areferencecte</em>bwhich referencescte_ain a non-recursive CTE; the database will return an error. - Naming collisions: if a CTE has the same name as a real table, the CTE takes precedence within that statement — a subtle source of bugs during refactoring.