CTE vs subquery vs view
A CTE, an inline subquery, and a view express the same logic in different places with different lifetimes — and a CTE once carried a quiet optimization caveat that a subquery never did.
Two engineers argue in review. One says “this should be a CTE, it’s cleaner.” The other says “a subquery in FROM is fine, and it’s faster.” Both are sometimes right — and before Postgres 12, the “faster” claim had real teeth, because a CTE wasn’t just a subquery with a name: it was an optimization fence the planner refused to look through. Knowing where each form lives, how long it lives, and how the planner treats it is the difference between a style opinion and an informed one.
Same logic, three places, three lifetimes
Why does the form matter if all three produce the same rows? Because lifetime determines reusability and — as the rest of the lesson shows — performance. The same subquery can appear in three structurally different forms. Take “categories whose parent is the root” against categories(id, parent_id):
-- 1. Inline / derived-table subquery: lives in the FROM of one statement
SELECT c.id
FROM categories c
JOIN (SELECT id FROM categories WHERE parent_id IS NULL) roots
ON c.parent_id = roots.id;
-- 2. CTE: named, still scoped to one statement
WITH roots AS (SELECT id FROM categories WHERE parent_id IS NULL)
SELECT c.id
FROM categories c
JOIN roots ON c.parent_id = roots.id;
-- 3. View: named, stored in the catalog, reusable across many statements
CREATE VIEW roots AS SELECT id FROM categories WHERE parent_id IS NULL;
SELECT c.id FROM categories c JOIN roots ON c.parent_id = roots.id; -- later, anywhereThe axis that distinguishes them is lifetime and visibility. An inline subquery has no name and exists only at its position in one statement. A CTE has a name but is still scoped to one statement — when the statement ends, the name is gone. A view has a name stored in the catalog: it persists, and any future query can read from it. A scalar subquery is a fourth shape — a subquery in parentheses that returns exactly one row and one column, usable anywhere a single value is expected, like WHERE created_at = (SELECT max(created_at) FROM orders).
When a plain subquery is the clearer choice
CTEs are not automatically better. A subquery in FROM is often clearer when the logic is used exactly once and is small — wrapping a three-line filter in a WITH can add ceremony without adding clarity. A scalar subquery in a SELECT or WHERE is the natural form for “compare each row to one aggregate value”; rewriting it as a CTE usually reads worse. And a correlated subquery — one that references the outer row, like WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) — has no clean CTE equivalent because it runs per outer row by design. Use the form that names what deserves a name and inlines what doesn’t.
The caveat that points to lesson 03
Here is the part that turned the review argument from style into substance. For most of Postgres’s history, a CTE was an unconditional optimization fence: the planner always evaluated the CTE separately and materialized its result, refusing to push predicates from the outer query down into it. An equivalent inline subquery was not fenced — the planner could flatten it, push filters in, and choose indexes accordingly. So the same logic could run very differently depending on which form you picked, and the CTE form was sometimes dramatically slower.
-- Pre-PG12: this CTE was materialized whole, then filtered — the WHERE
-- could NOT push into the CTE, so it scanned far more rows than needed.
WITH all_events AS (SELECT * FROM events) -- millions of rows materialized
SELECT * FROM all_events WHERE tenant_id = 7; -- filter applied AFTER
-- The equivalent subquery let the planner push tenant_id = 7 down to an index.
SELECT * FROM (SELECT * FROM events) e WHERE tenant_id = 7;Postgres 12 changed this default, and the keywords MATERIALIZED / NOT MATERIALIZED let you control it explicitly. That whole story — what changed, when materializing still helps, and how to read it in EXPLAIN — is lesson 03. For now, the takeaway is: CTE vs subquery is not purely cosmetic; it has historically affected the plan, and on older servers it still does.
▸Edge cases
A view adds a wrinkle the others don’t: it goes through the rewrite stage (recall the pipeline from unit 00). When you query a view, the planner substitutes the view’s definition into your query and then plans the combined whole — so a simple view is generally inlined and optimized together with your query, much like a modern non-materialized CTE. The exception is a MATERIALIZED VIEW, which is a stored snapshot you refresh explicitly — that one really is precomputed and read like a table. Plain views are query macros; materialized views are cached results.
You need the same named filter reusable across a dozen different reports run at different times. Which form fits?
On a Postgres 11 server, a report wrapped in a CTE is far slower than the same logic as a subquery in FROM. Most likely cause?
Fill in the blank: a CTE and a view express the same query, but a CTE's name lives for a single _______, while a view's name is stored in the catalog and persists across many of them.
- 01Rank CTE, inline subquery, and view by lifetime and visibility.
- 02When is a plain subquery clearer than a CTE?
- 03What was the historical optimization difference between a CTE and an equivalent subquery?
A CTE, an inline (derived-table) subquery, a scalar subquery, and a view are four ways to write the same logic, separated by lifetime and visibility: the scalar subquery and inline derived table are anonymous and live only at their spot in one statement; the CTE is named but still single-statement; the view is named, stored in the catalog, and reusable by any later query. Reach for the lowest form that meets your reuse need — a plain subquery is clearer for one-off and correlated logic, a CTE for a multi-step pipeline within one statement, a view for cross-statement reuse. The argument that elevates this above style is performance history: a CTE was, until Postgres 12, an unconditional optimization fence — materialized whole, immune to predicate pushdown — while the equivalent subquery was not, so the two could run wildly differently. Postgres 12 made CTEs inlinable by default and added MATERIALIZED / NOT MATERIALIZED to control it. Exactly when materializing helps, when it hurts, and how to see it in EXPLAIN is the entire next lesson. Now when you see a code review debate over “CTE vs subquery,” you’ll know the right question isn’t which looks cleaner — it’s how long the name needs to live and whether the planner version matters.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.