Here is an illustration to Verace's answer and discussion in the comments under it. The gist is that by trying NOT MATERIALIZED and MATERIALIZED in WITH-clauses, you are effectively doing the planner's optimisation work for it. If you have many CTEs, you might have to try many combinations. But if you re-write your CTEs as views, the planner can do better on its own.
In my particular example, the planner struggles with nested CTEs (PostgreSQL 16.3). Adding NOT MATERIALIZED reduces query execution time by orders of magnitude.
My Table_1 and Table_2 have 4M and 8M rows, columns include "client_id", "transaction_date" and attributes. I want to get this week's counts for selected clients, which should be a lightning fast index-only scan, because in both tables I have indices on ("transaction_date") including relevant columns.
The code can be found below. Note that I use cte_shorthand as syntactic sugar, and that it is nested in cte_1 and cte_2.
The query runs in ~100ms when I have:
WITH cte_shorthand AS NOT MATERIALIZED ...
WITH cte_1 AS ...
WITH cte_2 AS ...
However, the execution takes ~17s if I remove NOT MATERIALIZED option from cte_shorthand, even if I add it to other CTEs:
WITH cte_shorthand AS ...
WITH cte_1 AS [NOT MATERIALIZED] ...
WITH cte_2 AS [NOT MATERIALIZED] ...
In the query plan I can see that the planner used index-only scan on cte_1, but not on cte_2. Instead of pushing "transaction_date" condition into retrieving rows from Table_2, it did a nested loop join of Table_2 (sequential scan) with cte_shorthand and filtered out almost all rows. I could not find any explanation, because cte_1 and cte_2 are symmetric in most ways, yet are treated differently.
One can get even worse performance ~70s when all CTEs are materialised.
Converting cte_shorthand to inline expressions has the same effect as NOT MATERIALIZED and results in the optimal performance. My own takeaway from this is: CTEs should not be used as syntactic sugar.
CREATE VIEW view_outer AS (
WITH cte_shorthand AS NOT MATERIALIZED (
SELECT CURRENT_DATE - EXTRACT('dow' FROM NOW())::INT AS weekstart
),
cte_1 AS (
SELECT
"client_id" AS "client_id",
count(1) FILTER (WHERE ("attribute" = 1)) AS "counts_1",
count(1) FILTER (WHERE ("attribute" = 2)) AS "counts_2"
FROM "Table_1", cte_shorthand
WHERE ("transaction_date" >= cte_shorthand.weekstart)
GROUP BY "client_id"
),
cte_2 AS (
SELECT
"client_id" AS "client_id",
count(1) FILTER (WHERE ("attribute" = 1)) AS "counts_3",
FROM "Table_2", cte_shorthand
WHERE ("transaction_date" >= cte_shorthand.weekstart)
GROUP BY "client_id"
)
SELECT cte_1.*, "counts_3"
FROM cte_1,
FULL OUTER JOIN cte_2 ON cte_1."client_id" = cte_2."client_id"
);
-- Typical usage:
SELECT * FROM view_outer
WHERE "client_id" IN (1,2,3,4,10,11,12);