open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 05 · 03

CTE materialization: the fence that moved

Postgres 12 turned the old unconditional CTE optimization fence into smart inlining — and gave you MATERIALIZED / NOT MATERIALIZED to force either when the planner guesses wrong.

SQL Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

You upgrade a database from Postgres 11 to 14 and a report that took eight seconds now takes forty milliseconds — you changed nothing but the version. Or the reverse: a query that was fast for years suddenly regresses after the upgrade. Both are the same root cause. Postgres 12 quietly redefined what a CTE does to your plan. The old guarantee — “a CTE is always computed once, separately” — was a load-bearing assumption in a lot of production SQL, and when it went away, plans shifted. Knowing the rule, and the two keywords that override it, is senior-level table stakes.

The fence, and the day it moved

When you write a CTE, does Postgres actually separate it from the outer query, or does it fold everything together and let the planner optimize freely? The answer changed at a version boundary, and getting it wrong costs you seconds per query. Before Postgres 12, every CTE was an unconditional optimization fence. The planner computed each CTE exactly once, stored its full result in memory (materialized it), and then ran the outer query against that stored result. It never pushed the outer query’s filters down into the CTE. That made a CTE a predictable but often expensive boundary: handy when you wanted to compute something once and reuse it, ruinous when the outer query only needed a sliver of a huge CTE.

Postgres 12 changed the default to inlining: a CTE that is referenced once and is side-effect-free and not recursive is folded into the outer query, exactly as if you’d written it as a subquery — so predicates push down and indexes get used. If any of those conditions fails — the CTE is referenced more than once, or it contains a data-modifying statement (INSERT/UPDATE/DELETE ... RETURNING), or it’s recursive — Postgres still materializes it.

-- PG12+: referenced once, no side effects → inlined.
-- tenant_id = 7 pushes down into the CTE and hits an index.
WITH e AS (SELECT * FROM events)
SELECT * FROM e WHERE tenant_id = 7;

-- Referenced twice → materialized (computed once, reused).
WITH e AS (SELECT * FROM events WHERE created_at > now() - interval '1 day')
SELECT (SELECT count(*) FROM e) AS total,
       (SELECT count(*) FROM e WHERE tenant_id = 7) AS for_seven;

The two keywords that override the planner

You are not at the planner’s mercy. Two explicit keywords force the behavior:

-- Force materialization: compute once, even though referenced once.
WITH e AS MATERIALIZED (SELECT * FROM big_expensive_function_call())
SELECT * FROM e WHERE id = 42;

-- Force inlining: fold in even though referenced twice (you accept recomputation).
WITH e AS NOT MATERIALIZED (SELECT * FROM events WHERE tenant_id = 7)
SELECT ... ;

When to force MATERIALIZED: the CTE is genuinely expensive to compute (a costly function, a heavy aggregate) and referenced once, but inlining would cause that expensive work to run per outer row or be repeated; or the CTE has side effects you must run exactly once; or — the real-world rescue — inlining produced a worse plan and you want to restore the old fence behavior to break a bad join order. When to force NOT MATERIALIZED: the CTE is referenced multiple times so Postgres materialized it, but each reference only needs a filtered slice, and you’d rather the predicate push down than materialize the whole thing.

The trap cuts both ways. MATERIALIZED reintroduces the fence, so a selective WHERE in the outer query cannot push into the CTE — you may scan the entire base table to build the CTE, then filter. That’s the exact pre-12 footgun. Use MATERIALIZED deliberately, not as a reflex.

Spotting it in EXPLAIN

EXPLAIN tells you which path the planner took. A materialized CTE shows a CTE Scan node reading from a separately computed CTE <name> subplan; an inlined CTE shows no CTE Scan at all — the CTE’s tables appear directly in the main plan tree, and you’ll see the outer predicate applied as an Index Cond deep inside. So the diagnostic is simple: grep the plan for CTE Scan. Present means materialized; absent means inlined. The deep skill of reading these plans — cost numbers, estimate-vs-actual rows, join nodes — belongs to unit 08 and to the databases track’s execution-plans chapter; here you only need to recognize the fork.

Common mistake

The classic post-upgrade regression: a team relied on the pre-12 fence as a manual optimization. They wrote a CTE specifically to stop the planner from a bad choice — materializing it forced a sane join order. After upgrading to 12+, that same CTE got inlined, the planner reverted to its bad choice, and the query regressed 20×. The fix is one word: mark the CTE AS MATERIALIZED to restore the old behavior. The lesson is that the fence was load-bearing in places nobody documented — so when you upgrade across the 12 boundary, the CTEs that were silently acting as planner hints are exactly the ones to re-check with EXPLAIN.

Quiz

On Postgres 14, when does a CTE get inlined into the outer query by default?

Quiz

A CTE wraps a whole table and the outer query filters one tenant, but EXPLAIN shows a CTE Scan over millions of rows. Which one-word change likely fixes it on PG12+?

Complete the analogy

Fill in the blank: forcing a CTE AS MATERIALIZED reintroduces the optimization _______, so the outer query's predicates can no longer push down into the CTE.

Recall before you leave
  1. 01
    What was the pre-12 CTE behavior and what did Postgres 12 change?
  2. 02
    When should you force MATERIALIZED, and what's the cost?
  3. 03
    How do you tell from EXPLAIN whether a CTE was inlined or materialized?
Recap

The single most important fact about CTEs and performance is that the rule changed at Postgres 12. Before then, every CTE was an unconditional optimization fence: computed once, materialized in full, with no predicate pushdown — predictable, sometimes a useful manual hint, often needlessly expensive. Postgres 12 flipped the default to inlining for the common case — a CTE referenced exactly once, free of side effects, and non-recursive folds into the outer query like a subquery, so filters push down and indexes get used; everything else (multiple references, data-modifying CTEs, recursion) still materializes. You override the planner with two keywords: MATERIALIZED to force the compute-once fence (for genuinely expensive or side-effecting CTEs, or to restore a good plan inlining broke), and NOT MATERIALIZED to force inlining so a filter can push down through what would otherwise be a fence. The footgun is that MATERIALIZED blocks pushdown — the exact pre-12 cost — so use it on purpose. Read it in EXPLAIN by looking for a CTE Scan node: present means materialized, absent means inlined. The deeper plan-reading craft lives in unit 08 and the databases execution-plans chapter; here you own the fork and its two override keywords. Now when you see a CTE Scan over millions of rows in an EXPLAIN output, you’ll know exactly which keyword to reach for — and why the planner chose the fence in the first place.

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.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.