When should I reach for a CTE instead of a subquery?
Reach for a CTE when the query has distinct, nameable stages that you want to read in order, or when the same intermediate result is needed more than once. A subquery is an expression buried inside a clause, which is fine for one small step but becomes unreadable when you stack three of them inside each other.
The classic case where a CTE wins is a report with a filtering step that has to happen before an aggregate. A subquery version nests the filter inside a derived table inside the FROM clause, and the reader has to work out which part is which. The CTE version names the stages, so the intent reads top to bottom.
The other case is reuse. A subquery has to be repeated if you need it twice, and repeating an aggregate means running it twice. A CTE can be referenced several times, and on engines that materialise CTEs that is a genuine saving.
Where a subquery is better: a single, small filter. WHERE id IN (SELECT id FROM x WHERE flag = 1) is short, obvious, and gives the optimizer maximum freedom to push the predicate around. Wrapping the same thing in a CTE adds a layer without adding clarity.
The performance answer is that there usually is not one. On PostgreSQL and SQLite a non-recursive CTE is inlined and the plans are identical. On MySQL the CTE is materialised by default, which is slower for a single reference and faster for several. What actually changes the plan is the shape of the query — whether a predicate can be pushed into a derived table, and how many times an aggregate is computed — not the choice between the two syntaxes.
Here is the same thing as runnable code:
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL
);
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name VARCHAR(120) NOT NULL
);
INSERT INTO customers (id, name) VALUES
(1, 'Ada Lovelace'),
(2, 'Grace Hopper'),
(3, 'Alan Turing');
INSERT INTO orders (id, customer_id, amount, status) VALUES
(1, 1, 120.00, 'shipped'),
(2, 1, 340.50, 'pending'),
(3, 2, 75.25, 'shipped'),
(4, 2, 15.00, 'cancelled');
-- The same calculation written two ways.
SELECT c.name,
(SELECT SUM(o.amount) FROM orders AS o WHERE o.customer_id = c.id) AS total_spend
FROM customers AS c
ORDER BY c.id;
WITH customer_totals AS (
SELECT customer_id, SUM(amount) AS total_spend
FROM orders
GROUP BY customer_id
)
SELECT c.name, COALESCE(t.total_spend, 0) AS total_spend
FROM customers AS c
LEFT JOIN customer_totals AS t ON t.customer_id = c.id
ORDER BY c.id;