38 Common Table Expressions (WITH)
A common table expression (CTE) is a named subquery introduced with WITH name AS (SELECT ...). You can refer to it from the main query (and from later CTEs) as if it were a table. CTEs make complex queries readable: each CTE has a name and a job, and the main query reads top-down like a story. PostgreSQL also supports recursive CTEs (WITH RECURSIVE), which are how you walk trees, graphs, and chains in pure SQL.
What you’ll learn
- Defining a CTE with
WITH name AS (...) - Chaining multiple CTEs in one query
- Data-modifying CTEs:
INSERT,UPDATE,DELETEinsideWITH - Recursive CTEs for hierarchies and graphs
- The performance trade-offs (
MATERIALIZED/NOT MATERIALIZED)
Example
A CTE is just a named subquery. You can have many, comma-separated, each able to reference earlier ones. Read top-to-bottom, each step a clear chunk. Compared to a deeply nested set of derived tables, CTEs are usually easier to follow and easier to change.
Data-modifying CTEs are a PostgreSQL specialty: an INSERT, UPDATE, or DELETE with RETURNING can be the body of a CTE, and its returned rows flow into the next statement — atomically, in one round trip. The “delete from one table, insert into another” archive pattern shown above is the canonical example.
Recursive CTEs have two halves combined with UNION ALL: an anchor query that produces the initial rows, and a recursive query that references the CTE by name to add more rows. PostgreSQL keeps iterating until no new rows are produced. Use them for hierarchies (employees → manager), graph traversal (find all transitive dependencies), and series generators (numbers, dates).
Run the example
To run (requires tables from the earlier lessons):
Common pitfalls
- A nonrecursive, side-effect-free SELECT CTE referenced once is, in PostgreSQL 12+, normally folded into its parent query — the planner treats it like a subquery. If you want the older “always materialize” behavior, write
WITH x AS MATERIALIZED (...). WITH RECURSIVEaccepts bothUNION ALLandUNION.UNIONeliminates duplicate complete rows, including rows produced earlier;UNION ALLkeeps them. Choose based on the recursion and its termination condition.- A recursive CTE that does not converge (the recursive step keeps adding rows) is an infinite loop. The query may run indefinitely, spill to disk, or fail;
work_memis not an execution limit. Add a termination condition (WHERE depth < 100etc.) when in doubt. - Data-modifying CTEs inside
WITHare evaluated in unspecified order relative to each other — do not rely on side effects between them. The main statement shares the same snapshot and has no guaranteed modification order relative to them. Pass data throughRETURNINGrather than relying on visible side effects.
When a query gets longer than ~20 lines or has more than two derived tables, refactoring into named CTEs almost always wins. You can even leave one CTE temporarily and inline another to see where the cost is.
Write a CTE that computes the average order total, then in the main query show every order with the difference from the average. Then try a recursive CTE that produces the numbers 1 through 10 (WITH RECURSIVE t(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM t WHERE n < 10) SELECT * FROM t;).
Downloads
SQL file: cte.sql · All lesson files · Setup guide