Common Table Expressions (CTE)
WITH clause and then reference that name later in the same statement — as if it were a temporary, throwaway table that exists only for the duration of the query.Basic syntax
WITH name AS ( SELECT ... ) SELECT * FROM name WHERE ...;
Why CTEs beat deeply nested subqueries
Once a query needs two or three levels of subquery nesting, it becomes hard to read from the inside out. CTEs let you flatten that nesting into a sequence of named, top-to-bottom steps — closer to how you'd actually explain the query out loud.
Before — nested subqueries
SELECT region, avg_order_value FROM ( SELECT region, AVG(total) AS avg_order_value FROM orders WHERE status = 'delivered' GROUP BY region ) AS region_avg WHERE avg_order_value > ( SELECT AVG(total) FROM orders WHERE status = 'delivered' );
After — same query using CTEs
WITH delivered_orders AS ( SELECT * FROM orders WHERE status = 'delivered' ), region_avg AS ( SELECT region, AVG(total) AS avg_order_value FROM delivered_orders GROUP BY region ), overall_avg AS ( SELECT AVG(total) AS avg_order_value FROM delivered_orders ) SELECT region_avg.region, region_avg.avg_order_value FROM region_avg, overall_avg WHERE region_avg.avg_order_value > overall_avg.avg_order_value;
delivered_orders is defined once and referenced twice, the intent ("filter first, then compute two different averages from the same filtered set") is much clearer than reading the nested version inside-out.Multiple CTEs in one WITH clause
WITH can define several CTEs, separated by commas. Later CTEs can reference earlier ones defined in the same WITH clause.PostgreSQL 12+: CTEs are no longer always an optimization fence
This is a genuinely important, version-specific detail. Before PostgreSQL 12, every CTE was materialized — computed in full and stored in a temporary work area — before the outer query could use it. This was called an "optimization fence": it prevented the planner from pushing filters or reordering work across the CTE boundary, which sometimes made CTEs surprisingly slow compared to writing the equivalent subquery inline.
FOR UPDATE, or being referenced by a recursive query). This generally makes CTEs perform the same as an equivalent subquery, removing the old reason to avoid them for performance reasons.Force materialization when you want the old fence behavior
WITH expensive_calc AS MATERIALIZED ( SELECT customer_id, SUM(total) AS lifetime_value FROM orders GROUP BY customer_id ) SELECT * FROM expensive_calc WHERE lifetime_value > 1000;
AS MATERIALIZED when you deliberately want the CTE computed once and reused as-is — for example, if it's referenced many times and recomputing it per reference would be wasteful, or if inlining would let the planner make a bad choice for an expensive computation. Use AS NOT MATERIALIZED to explicitly request inlining even in cases where PostgreSQL might otherwise materialize.MATERIALIZED keyword to opt out, because materializing was the only behavior available.WITH RECURSIVE, covered on the next page.WITH name AS (...)gives a subquery a name you can reference later in the same statement.CTEs flatten deeply nested subqueries into readable, named, sequential steps.
PostgreSQL 12+ can inline a singly-referenced CTE into the outer query and optimize across it, removing the old always-materializes behavior.
Use
AS MATERIALIZEDto force the pre-12 fence behavior when you need it;AS NOT MATERIALIZEDto force inlining.