Common Table Expressions (CTEs)
CTE materialization and query performance
CTEs are useful for organizing complicated queries.
But there is another question we need to understand:
How does PostgreSQL actually execute the CTE?
You may encounter the claim:
1PostgreSQL always calculates a CTE separately before executing the outer query.That is not true for current PostgreSQL.
For certain CTEs, PostgreSQL can combine the CTE with the surrounding query and optimize them together.
For others, PostgreSQL calculates the CTE separately.
This distinction is called CTE materialization.
A simple CTE
Consider:
12345678910WITH selected_customers AS ( SELECT id, full_name, country FROM customers)SELECT *FROM selected_customersWHERE id = 9189;At first glance, you might imagine PostgreSQL doing this:
1234567SELECT all customers │ ▼store CTE result │ ▼filter id = 9189That would mean processing all 10,000 customers and then keeping one.
Current PostgreSQL does not necessarily do that.
For a non-recursive, side-effect-free SELECT CTE that is referenced once, PostgreSQL can fold the CTE into the parent query.
Conceptually, it can optimize the query more like:
123456SELECT id, full_name, countryFROM customersWHERE id = 9189;The CTE remains useful for organizing the SQL, but it does not necessarily create a separate intermediate result during execution.
Seeing this with EXPLAIN ANALYZE
Run:
1234567891011EXPLAIN ANALYZEWITH selected_customers AS ( SELECT id, full_name, country FROM customers)SELECT *FROM selected_customersWHERE id = 9189;You should see a plan like this:
1234567891011 QUERY PLAN------------------------------------------------------------------------------------------------------------------------------ Index Scan using customers_pkey on customers (cost=0.29..8.30 rows=1 width=20) (actual time=0.021..0.023 rows=1.00 loops=1) Index Cond: (id = 9189) Index Searches: 1 Buffers: shared hit=3 Planning: Buffers: shared hit=31 Planning Time: 0.117 ms Execution Time: 0.137 ms(8 rows)Your timing and buffer values will probably differ from the ones shown here.
There is no CTE Scan in this plan at all. The CTE was folded away, and PostgreSQL pushed:
1id = 9189down to the scan of customers. Because customers.id is a primary key and already has an index, that condition became an Index Cond and the query read a single row.
The exact execution plan can vary, but the important idea is that PostgreSQL is allowed to optimize the CTE together with the outer query rather than first producing all CTE rows.
What is materialization?
When PostgreSQL materializes a CTE, it calculates the CTE separately and makes that result available to the rest of the query.
Conceptually:
1234567891011base table │ ▼execute CTE │ ▼materialized result │ ├────────▶ use 1 │ └────────▶ use 2The outer query then works with the CTE result rather than optimizing directly against the underlying table.
This can be useful in some situations and harmful in others.
Forcing materialization
PostgreSQL allows us to explicitly request materialization:
12345678910WITH selected_customers AS MATERIALIZED ( SELECT id, full_name, country FROM customers)SELECT *FROM selected_customersWHERE id = 9189;Now PostgreSQL is instructed to calculate selected_customers separately.
Run:
1234567891011EXPLAIN ANALYZEWITH selected_customers AS MATERIALIZED ( SELECT id, full_name, country FROM customers)SELECT *FROM selected_customersWHERE id = 9189;This time the plan looks quite different:
12345678910111213 QUERY PLAN------------------------------------------------------------------------------------------------------------------------- CTE Scan on selected_customers (cost=204.00..429.00 rows=1 width=68) (actual time=4.542..4.868 rows=1.00 loops=1) Filter: (id = 9189) Rows Removed by Filter: 9999 Storage: Memory Maximum Storage: 533kB Buffers: shared hit=104 CTE selected_customers -> Seq Scan on customers (cost=0.00..204.00 rows=10000 width=20) (actual time=0.008..1.976 rows=10000.00 loops=1) Buffers: shared hit=104 Planning Time: 0.079 ms Execution Time: 4.882 ms(10 rows)Three lines are worth reading closely:
Seq Scan on customers ... rows=10000shows that the CTE read all10,000customers.Rows Removed by Filter: 9999shows thatid = 9189was applied afterwards, to the CTE result.Storage: Memory Maximum Storage: 533kBshows that the intermediate result was actually stored.
The index on customers.id was never used, and the execution time went from a fraction of a millisecond to several milliseconds.
The important difference is conceptual:
12345678without forced materializationcustomers │ └── apply id = 9189 while accessing customers │ ▼ resultversus:
123456789101112MATERIALIZEDcustomers │ ▼calculate CTE result │ ▼CTE result │ ▼filter id = 9189Materialization can therefore prevent an outer condition from being pushed directly into the underlying table scan.
NOT MATERIALIZED
PostgreSQL also provides:
1NOT MATERIALIZEDFor example:
1234567891011EXPLAIN ANALYZEWITH selected_customers AS NOT MATERIALIZED ( SELECT id, full_name, country FROM customers)SELECT *FROM selected_customersWHERE id = 9189;The plan returns to the folded shape:
123456789 QUERY PLAN------------------------------------------------------------------------------------------------------------------------------ Index Scan using customers_pkey on customers (cost=0.29..8.30 rows=1 width=20) (actual time=0.013..0.015 rows=1.00 loops=1) Index Cond: (id = 9189) Index Searches: 1 Buffers: shared hit=3 Planning Time: 0.129 ms Execution Time: 0.036 ms(6 rows)NOT MATERIALIZED tells PostgreSQL to allow the CTE to be merged into the parent query rather than forcing a separately calculated result.
This can make it possible for conditions such as:
1WHERE id = 9189to participate directly in optimization of the underlying query.
A useful mental model is:
12345NOT MATERIALIZED→ allow CTE and outer query to be optimized togetherMATERIALIZED→ calculate CTE separatelyThe default behavior
For a non-recursive, side-effect-free `SELECT` CTE, PostgreSQL can fold the CTE into its parent query.
By default, a useful rule to remember is:
12345CTE referenced once→ PostgreSQL can normally fold it into the parent queryCTE referenced multiple times→ PostgreSQL normally keeps a separately evaluated CTE resultThis is not a reason to automatically add NOT MATERIALIZED whenever a CTE is referenced more than once.
There is a trade-off.
Why multiple references matter
Imagine:
1234567891011EXPLAIN ANALYZEWITH customer_data AS ( SELECT id, country FROM customers)SELECT *FROM customer_data aINNER JOIN customer_data b ON a.id = b.id;customer_data is referenced twice.
You should see a plan like this:
12345678910111213141516171819202122 QUERY PLAN----------------------------------------------------------------------------------------------------------------------------------- Hash Join (cost=529.00..866.50 rows=10000 width=72) (actual time=5.532..10.051 rows=10000.00 loops=1) Hash Cond: (a.id = b.id) Buffers: shared hit=104 CTE customer_data -> Seq Scan on customers (cost=0.00..204.00 rows=10000 width=7) (actual time=0.006..1.437 rows=10000.00 loops=1) Buffers: shared hit=104 -> CTE Scan on customer_data a (cost=0.00..200.00 rows=10000 width=36) (actual time=0.007..1.372 rows=10000.00 loops=1) Storage: Memory Maximum Storage: 377kB Buffers: shared hit=2 -> Hash (cost=200.00..200.00 rows=10000 width=36) (actual time=5.520..5.521 rows=10000.00 loops=1) Buckets: 16384 Batches: 1 Memory Usage: 367kB Buffers: shared hit=102 -> CTE Scan on customer_data b (cost=0.00..200.00 rows=10000 width=36) (actual time=0.000..4.240 rows=10000.00 loops=1) Storage: Memory Maximum Storage: 377kB Buffers: shared hit=102 Planning: Buffers: shared hit=11 Planning Time: 0.158 ms Execution Time: 10.919 ms(19 rows)There is one Seq Scan on customers but two CTE Scan nodes. PostgreSQL calculated the CTE once and both sides of the join read that stored result.
If PostgreSQL independently expanded the CTE into both locations, some work might be repeated.
Materializing the CTE allows PostgreSQL to calculate it once and reuse its output.
Conceptually:
1234567calculate once │ ▼customer_data │ │ ▼ ▼ use 1 use 2This is one reason materialization can be useful.
When materialization can hurt
Now imagine a CTE contains many rows:
110,000 customersbut the outer query needs only:
1customer 9189If the CTE is materialized first, PostgreSQL may have to calculate the larger intermediate result before applying the outer filter.
If the CTE can be folded into the outer query, PostgreSQL may instead apply the filter while accessing the base table.
Conceptually:
123456789101112materialized10,000 rows │ ▼CTE result │ ▼filter │ ▼1 rowversus:
123456789foldedcustomers │ ▼filter using id = 9189 │ ▼1 rowThat is exactly the difference the two plans earlier in this chapter showed.
So materialization can sometimes prevent useful query optimizations.
When materialization can help
Materialization can also be beneficial.
Imagine a CTE performs an expensive calculation and the result is used several times:
12345678910base rows │ ▼expensive calculation │ ▼CTE result │ │ ▼ ▼use 1 use 2If the result is materialized, PostgreSQL can perform the expensive work once and reuse the result.
If the CTE were instead expanded into each use, the calculation could potentially be repeated.
So:
12materializationcan prevent repeated workwhile:
12not materializingcan allow better pushdown and optimizationThere is no rule that one is always faster.
MATERIALIZED is not a general performance fix
Seeing:
1MATERIALIZEDdoes not mean:
1make this query fasterSimilarly:
1NOT MATERIALIZEDdoes not mean:
1make this query fasterThey change how PostgreSQL is allowed to plan the CTE.
Whether that helps depends on the query.
If performance matters, compare the execution plans with:
1EXPLAIN ANALYZErather than guessing.
These rules don't apply to every CTE
The folding behavior discussed above applies to non-recursive, side-effect-free SELECT CTEs.
A recursive CTE is different.
As you saw in the previous chapter, PostgreSQL evaluates recursive CTEs iteratively using a working table.
They are not treated like a simple non-recursive CTE that can be folded into the parent query.
Data-modifying CTEs are also different because they perform operations such as:
1234INSERTUPDATEDELETEMERGEThe materialization discussion in this chapter is primarily about ordinary SELECT CTEs.
Do not choose CTEs based only on old performance advice
You may encounter older PostgreSQL advice saying:
1CTEs are always optimization barriers.That description does not match current PostgreSQL behavior.
For current PostgreSQL, a suitable non-recursive SELECT CTE can be folded into the parent query and optimized together with it.
This means the first question when deciding whether to use a CTE should often be:
1Does this make the query easier to understand?Then, if the query is performance-sensitive:
1inspect the execution planand decide whether materialization behavior matters.
The core mental model
Think of the two possibilities like this:
123456folded CTECTE + outer query │ ▼optimized togetherversus:
123456789materialized CTECTE │ ▼separate result │ ▼outer queryMATERIALIZED can be useful when calculating a result once and reusing it is valuable.
NOT MATERIALIZED can be useful when allowing the outer query to optimize directly against the underlying tables is more valuable.
The important point is:
A CTE is a way to structure SQL, but its execution is still a query-planning decision. Use `EXPLAIN ANALYZE` when the performance difference matters.