- Learn
- PostgreSQL
- PostgreSQL CTE: WITH queries explained
PostgreSQL CTE: WITH queries explained
CTEs name a subquery so you can read it. Since PostgreSQL 12 they are inlined by default, so they are no longer an optimization fence.
Quick answer
A common table expression names a subquery so the main query stays readable:
The CTE exists only for the duration of that statement.
Basic syntax
Chain several, and later ones can reference earlier ones:
Note the comma between CTEs, and that WITH appears only once. Reading that top to bottom describes the pipeline, which is the whole reason to use them.
CTEs are no longer an optimization fence
This is the thing most articles on the subject still get wrong, because it changed in PostgreSQL 12.
Before 12, PostgreSQL always materialized a CTE: it ran the subquery to completion, stashed the result, then used it. That meant a CTE could not be optimized together with the outer query, and a filter outside the CTE could not be pushed inside it. People used this deliberately as a planner hint, and other people got badly bitten by it.
From PostgreSQL 12, a CTE that is referenced once and has no side effects is inlined, exactly like a subquery.
You can see it. With a 50,000-row table indexed on id:
The WHERE id = 42 got pushed into the CTE and used the index. On PostgreSQL 11 this would have scanned all 50,000 rows first.
Force the old behaviour with MATERIALIZED:
Sequential scan of the whole table, then filter. That is the fence, back on request.
When to use MATERIALIZED
Inlining is the right default, but there are cases where you want the fence:
- The CTE is expensive and referenced several times. Inlining means running it once per reference. Materializing runs it once.
- The subquery has side effects, or calls a volatile function you want evaluated exactly once.
- You know better than the planner, which is rarer than it feels, but does happen with bad estimates.
NOT MATERIALIZED forces inlining even when a CTE is referenced multiple times.
A CTE that is referenced more than once is materialized by default, so the common case is already handled.
Recursive CTEs
WITH RECURSIVE is how you walk a tree or a graph. It is also the part people find genuinely hard, so it is worth taking slowly.
The shape is always the same:
A concrete example. Given an employee table where each row points at a manager:
Find everyone under Ada, with their depth:
What happens mechanically: the base case runs once and produces Ada. The recursive term then runs against only the rows produced by the previous round, not the whole accumulated set. It repeats until a round returns nothing.
That last point is the one that clears up most confusion. org inside the recursive term does not mean "everything so far", it means "what the last iteration produced".
Generating a series
Recursion is not only for trees:
For this specific job generate_series(1, 5) is simpler and faster. But the pattern generalizes to things generate_series cannot do.
Guarding against infinite loops
If your data has a cycle, UNION ALL will recurse forever. Two defences.
UNION instead of UNION ALL deduplicates, which stops a loop that revisits identical rows, at the cost of a distinct on every round.
A depth limit is blunter and more predictable:
For real cycle detection, track the path:
PostgreSQL 14 added a CYCLE clause that does this for you:
Note that RECURSIVE goes after WITH, once, even if only one of several CTEs is actually recursive.
Data-modifying CTEs
INSERT, UPDATE, and DELETE can live inside a CTE, which is how you move rows between tables in one statement:
The RETURNING is what makes this work: it hands the deleted rows to the next stage.
Two rules that matter here.
Data-modifying CTEs always run, whether or not the outer query references them. They are never inlined and never skipped.
They all see the same snapshot. Sub-statements cannot see each other's changes. So this does not do what it looks like:
The SELECT reads the table as it was before the statement began. To see the new rows, select from the CTE, not the table.
The execution order of several data-modifying CTEs is also not defined. If two of them touch the same row, the outcome is unpredictable. Do not rely on ordering.
CTE vs subquery vs view
| CTE | Subquery | View | |
|---|---|---|---|
| Scope | One statement | One statement | Permanent |
| Named | Yes | Usually not | Yes |
| Reusable in the query | Yes | No | Yes |
| Recursive | Yes | No | No |
| Can modify data | Yes | No | No |
Since 12, a single-use CTE and an equivalent subquery produce the same plan, so the choice is about readability. Use a CTE when naming the step explains the query. Use a view when several queries need the same step.
Common problems
relation "my_cte" does not exist. A CTE only exists for its own statement. You cannot reference it in a later query, and you cannot reference a CTE from a sibling CTE defined after it.
My CTE got slower on an upgrade. You relied on the pre-12 fence. Add MATERIALIZED to get it back.
My recursive CTE runs forever. A cycle in the data. Add a depth limit, use UNION, or track the path.
ERROR: recursive reference to query "x" must not appear more than once. The recursive term can only mention the CTE once. Restructure the join.
Quick reference
| Task | Syntax |
|---|---|
| Basic CTE | WITH c AS (SELECT ...) SELECT * FROM c |
| Several CTEs | WITH a AS (...), b AS (...) SELECT ... |
| Force materialization | WITH c AS MATERIALIZED (...) |
| Force inlining | WITH c AS NOT MATERIALIZED (...) |
| Recursive | WITH RECURSIVE c AS (base UNION ALL recursive) |
| Cycle detection (PG14+) | CYCLE id SET is_cycle USING path |
| Move rows between tables | WITH d AS (DELETE ... RETURNING *) INSERT ... |