Working with CTEs
A Common Table Expression (CTE) is a named subquery declared with WITH at the
top of a statement. It exists only for that statement, and it can be referenced
like a relation anywhere below its declaration — including from later CTEs in
the same WITH list.
WITH recent AS (
SELECT * FROM orders WHERE created_at > '2024-01-01'
)
SELECT customer_id, COUNT(*) AS order_count
FROM recent
GROUP BY customer_id;CTEs are for readability and reuse: name the intermediate result once instead of repeating the subquery, and build a pipeline of transformations as a sequence of named steps rather than a tower of nested parentheses.
Scope and shadowing
- A CTE is visible to the main query and to CTEs declared after it in the
same
WITHlist — never to earlier ones, and a CTE cannot reference itself (except underRECURSIVE, below). - A CTE shadows a catalog relation of the same name for the duration of the statement.
- A view's body is a closed unit: it sees its own CTEs, never the CTEs of the query that reads it.
One body, many references
A CTE referenced once is folded into the plan at its reference — it costs nothing over writing the subquery inline. A CTE referenced two or more times is materialized once and every reference reads the same result:
WITH totals AS (
SELECT customer_id, SUM(amount) AS spend
FROM orders
GROUP BY customer_id
)
SELECT big.customer_id
FROM totals AS big
JOIN totals AS everyone ON everyone.spend < big.spend;totals executes once here, not twice. A self-join of a CTE with itself is two
references to one materialized result, not two executions.
Two consequences worth knowing:
- Shared work is coordinated, not duplicated. A column no reference reads is never materialized, and a filter applied identically above every reference is moved into the body. A filter above only some references stays where it is — dropping rows one consumer needs would be a wrong answer, not an optimization.
- Making a CTE single-reference changes the plan shape. If you fold a second reference away, the body is inlined again and optimized in place.
Recursive CTEs
WITH RECURSIVE lets a CTE reference itself, which turns it into an iterative
computation: hierarchies, reachability, sequences — anything defined as "start
here, then repeatedly apply a step until nothing new appears".
WITH RECURSIVE <name> [ (<columns>) ] AS (
<anchor query> -- the starting rows; must not reference <name>
UNION [ ALL ]
<recursive query> -- the step; references <name> exactly once
)
SELECT ... FROM <name> ...Evaluation is semi-naive: the anchor's rows are the first frontier; each pass runs the recursive query with the self-reference bound to only the previous pass's rows, and appends what it produces to the result. When a pass produces nothing new, the recursion is finished. On a graph this is exactly breadth-first frontier expansion — each pass sees the newly-discovered rows, never the whole accumulated result.
Counting and sequences
WITH RECURSIVE series (n) AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM series WHERE n < 10
)
SELECT n FROM series ORDER BY n;For a plain integer sequence, prefer GENERATE_SERIES — it is a single native
call. Reach for recursion when each row genuinely depends on the previous one:
WITH RECURSIVE fib AS (
SELECT 1 AS pos, 0 AS val, 1 AS next_val
UNION ALL
SELECT pos + 1, next_val, val + next_val
FROM fib
WHERE pos < 10
)
SELECT pos, val AS fibonacci_number
FROM fib
ORDER BY pos;Hierarchies and graph traversal
Walking an edge list is the canonical use. The recursive query joins the frontier to the edges to discover the next frontier:
WITH RECURSIVE reach (node) AS (
SELECT 1 AS node -- start at node 1
UNION
SELECT e.dst
FROM reach
JOIN edges AS e ON e.src = reach.node
)
SELECT node FROM reach ORDER BY node;UNION vs UNION ALL — this choice matters
UNIONdeduplicates every produced row against everything the recursion has already emitted. A row seen before does not re-enter the frontier — which is precisely what makes traversal of a cyclic graph terminate. Use it whenever the data can revisit a state.UNION ALLappends unconditionally. It is cheaper (no dedup) and right for recursions that provably move forward — a counter that only increases, a strictly-deepening hierarchy.
A UNION ALL recursion over cyclic data never converges. It does not hang and
it does not silently truncate: the iteration ceiling
(MAX_RECURSION_ITERATIONS, default 1000) stops it with an error that names
the CTE. The fixes, in order of preference: switch to UNION, bound the
recursion with a depth column, or — if the recursion is legitimate and deep —
raise the ceiling.
-- depth-bounded traversal under UNION ALL
WITH RECURSIVE walk (node, depth) AS (
SELECT 1 AS node, 0 AS depth
UNION ALL
SELECT e.dst, depth + 1
FROM walk
JOIN edges AS e ON e.src = walk.node
WHERE depth < 6
)
SELECT DISTINCT node FROM walk;Rules the planner enforces
Each of these is rejected with a specific error when the query is planned — none of them fails at runtime or computes a wrong answer:
- The body must be
<anchor> UNION [ALL] <recursive query>, and only the recursive query may reference the CTE — exactly once, directly in itsFROMclause. (RECURSIVEis permission, not obligation: a CTE underWITH RECURSIVEthat never references itself plans as an ordinary CTE.) - Both queries must produce the same number of columns with the same
types. There is no silent widening — where the types differ, the error
names the column and you add an explicit
CAST. - The recursive query may not apply aggregation, window functions,
ORDER BYorLIMITover the self-reference, and may only reach it throughINNERjoins. Apply those in the query that reads the CTE. - Mutual recursion (two CTEs referencing each other) is not supported.
Ordering
Result order without an ORDER BY is unspecified — for recursive CTEs as for
every other query. Do not rely on rows appearing in iteration order (some
engines happen to emit that way; Opteryx does not promise it). Add ORDER BY
to the reading query when order matters.
Observing a recursion
EXPLAIN renders each recursive CTE as its own section with the anchor and
recursive-term plans; EXPLAIN ANALYZE adds what the fixpoint actually did —
the passes it ran and, for UNION, the distinct rows its visited set held:
RECURSIVE CTE reach | UNION, 3 iterations, 4 distinct rows, ceiling 1000
├─ ANCHOR
│ └─ ...
└─ RECURSIVE TERM
└─ ...
An unexpectedly large iteration count is the first thing to look at when a
recursive query is slow: it usually means the frontier is not shrinking —
revisiting states under UNION ALL, or a step condition that filters less
than intended.
Performance notes
- Filter inside the body, not just above it. Filters above a multiply- referenced or recursive CTE are not pushed into it unless every reference agrees (and never into a recursive body — filtering the frontier is not the same operation as filtering the result). If only part of the data matters, say so in the anchor and in the CTE body.
- Keep the frontier narrow. The recursive query re-runs every pass; columns it carries are carried through every iteration. Carry the columns the step needs, and join the rest back on afterwards.
- Prefer set-based steps. Each pass processes the whole frontier at once — a step that expands many rows per pass converges in few passes. A recursion that advances one row per pass (like the fibonacci example) works, but the per-pass cost is paid per row; that shape is for when the dependency is genuinely sequential.
See Also
- WITH — syntax reference
- SELECT
- Relation Constructors —
VALUESandUNNEST, handy for edge lists in examples and tests