Think of a CTE as a named sticky note for one query, while a subquery is the same logic written inline. Interviewers love this question because the syntax is easy, but the real answer is about readability, reuse, and what the optimizer does.
Question: What is the difference between a CTE and a subquery in SQL?
Answer: A CTE, or Common Table Expression, is a named temporary result set that exists for one statement. A subquery is a query written inside another query, usually in the FROM, WHERE, or SELECT clause. Both can produce the same result, but CTEs are often easier to read and reuse.
Interview-Ready Answer: I’d say a CTE is just a named way to break a query into steps, while a subquery is the same kind of logic embedded inline. I use a CTE when I want clearer structure, repeated reuse, or recursion; I use a subquery when the logic is short and only needed once. Performance is not automatically better with either one, because many databases optimize them into the same plan, so I always check the execution plan if the query is important.
A CTE is a named query introduced by WITH. It acts like a one-time building block for the rest of the statement. A subquery is simply a query nested inside another query and usually has no name of its own.
| Aspect | CTE | Subquery |
|---|---|---|
| Readability | Usually clearer | Can get nested |
| Reuse | Easy to reference | Often repeated |
| Scope | One statement | One statement |
| Recursion | Supported | Not practical |
| Optimizer | May inline or materialize | May be flattened |
There is no universal rule that a CTE is faster or slower than a subquery. The real cost depends on the plan: scans, joins, sorting, grouping, and whether the engine reuses an intermediate result. On a table with 10 million rows, repeating the same expensive aggregate three times can triple CPU and I/O if the optimizer does not remove the duplication. On the other hand, forcing a large result to materialize can write temp data to disk and also slow things down.
In PostgreSQL 12 and newer, non-recursive CTEs can often be inlined by default, and you can control this with MATERIALIZED or NOT MATERIALIZED. Other databases may handle CTEs differently, so the safe interview answer is: check the execution plan, not just the syntax.
WITH.ORDER BY inside a subquery or CTE does not guarantee final order unless the outer query also orders the result.Memory model: a CTE is a labeled box on your desk for this one sentence; a subquery is the same box hidden inside the sentence. Both can contain the same contents, but the labeled box is easier to pick up and reuse.
Imagine a checkout service for an e-commerce app that computes a daily high-risk customers report. The original query used the same expensive aggregation three times as subqueries: once to filter customers, once to rank them, and once to show the totals. At 50,000 orders a day it was fine, but after growth to 20 million rows, the dashboard started timing out.
The team refactored the query into a CTE so the aggregate was defined once and then reused. That made the SQL much easier to review, and in their database the optimizer produced a better plan. The outage symptom was classic: API requests backed up, the report endpoint returned 504s, and the database logs showed repeated statement timeouts during peak hours.
What went wrong: the engineers had assumed every subquery would be computed once and every CTE would always be cached. In reality, the execution plan decided the true cost. The fix was not just syntax; it was simplifying the query, checking the plan, and adding the right index on the join/filter columns.
-- Self-contained demo: same business rule written with a CTE and with subqueries.
-- The data includes a NULL amount to show why COALESCE matters in real reports.
WITH orders AS (
SELECT CAST(1 AS INTEGER) AS order_id, CAST(101 AS INTEGER) AS customer_id, CAST(120.00 AS DECIMAL(10,2)) AS amount
UNION ALL SELECT 2, 101, CAST(80.00 AS DECIMAL(10,2))
UNION ALL SELECT 3, 102, CAST(40.00 AS DECIMAL(10,2))
UNION ALL SELECT 4, 103, CAST(200.00 AS DECIMAL(10,2))
UNION ALL SELECT 5, 104, CAST(NULL AS DECIMAL(10,2))
),
customer_totals AS (
SELECT
customer_id,
SUM(COALESCE(amount, 0)) AS total_spent
FROM orders
GROUP BY customer_id
)
SELECT 'CTE' AS approach, customer_id, total_spent
FROM customer_totals
WHERE total_spent > (
SELECT AVG(total_spent)
FROM customer_totals
)
UNION ALL
SELECT 'Subquery' AS approach, customer_id, total_spent
FROM (
SELECT
customer_id,
SUM(COALESCE(amount, 0)) AS total_spent
FROM orders
GROUP BY customer_id
) AS t
WHERE total_spent > (
SELECT AVG(x.total_spent)
FROM (
SELECT
customer_id,
SUM(COALESCE(amount, 0)) AS total_spent
FROM orders
GROUP BY customer_id
) AS x
)
ORDER BY approach, customer_id;
-- Expected result: customer 101 and 103 are above the average spend.
-- The NULL amount does not break the report because COALESCE turns it into 0.
-- This is the kind of small defensive step that keeps reporting queries stable.Follow-up & Tricky Questions:
WITH or inside parentheses.Common Mistakes:
ORDER BY in a nested query does not guarantee final order. Correction: order only at the outermost level unless your dialect has a special rule.Memory Hook: CTE = labeled box, subquery = box inside the sentence. If you need to reuse or explain the box, label it. If it is a one-line thought, keep it inline.
Cheat Sheet:
WITH name AS (...) and lasts for one statement.WITH.Practice Tasks: