-- Products priced above the average active productSELECT name, priceFROM productsWHERE price > (SELECT avg(price) FROM products WHERE is_active)ORDER BY price DESC;-- A scalar subquery returning >1 row raises: ERROR: more than one row returned by a subquery used as an expression
Derived Table (Subquery in FROM)
SELECT status, round(avg(total), 2) AS avg_order_valueFROM ( SELECT o.id, o.status, sum(oi.quantity * oi.unit_price) AS total FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id) per_order -- alias requiredGROUP BY statusORDER BY avg_order_value DESC;
Correlated Subquery
What: The inner query references a column of the outer query, so it is logically run once per outer row
Performance: Postgres often decorrelates EXISTS/IN into joins; correlated scalar subqueries in SELECT usually run as a SubPlan per row — fine for small outputs, slow for millions
Rewrite Options: Join to a pre-aggregated derived table, or LATERAL
-- Each product with how many times it was ordered (correlated scalar in SELECT)SELECT p.name, (SELECT count(*) FROM order_items oi WHERE oi.product_id = p.id) AS times_orderedFROM products pORDER BY times_ordered DESC, p.nameLIMIT 5;-- Products priced above their category's average (correlated in WHERE)SELECT p.name, p.category_id, p.priceFROM products pWHERE p.price > (SELECT avg(p2.price) FROM products p2 WHERE p2.category_id = p.category_id)ORDER BY p.category_id;
-- EXISTS: categories that have at least one out-of-stock productSELECT c.name FROM categories cWHERE EXISTS (SELECT 1 FROM products p WHERE p.category_id = c.id AND p.stock = 0);-- IN with a list from a subquerySELECT full_name FROM usersWHERE id IN (SELECT user_id FROM orders WHERE status = 'cancelled');-- = ANY with an array (what drivers send for "WHERE id IN (…)" lists)SELECT id, name FROM products WHERE id = ANY (ARRAY[1, 3, 5]);-- > ALL: products more expensive than every bookSELECT name, price FROM productsWHERE price > ALL (SELECT price FROM products WHERE category_id = 8) AND category_id <> 8ORDER BY priceLIMIT 3;
CTEs (WITH)
Why Use CTEs
Readability: Name each step; read the query top-down like a pipeline
Reuse: Reference the same CTE several times in the main query
Recursion: Only CTEs can recurse
Data-Modifying CTEs: INSERT/UPDATE/DELETE … RETURNING inside WITH — chain writes in one statement (see Inserts Updates Upserts and MERGE)
WITH order_totals AS ( SELECT order_id, sum(quantity * unit_price) AS total FROM order_items GROUP BY order_id), customer_value AS ( SELECT o.user_id, sum(t.total) AS lifetime_value, count(*) AS orders FROM orders o JOIN order_totals t ON t.order_id = o.id WHERE o.status IN ('paid', 'shipped', 'delivered') GROUP BY o.user_id)SELECT u.full_name, cv.orders, cv.lifetime_value, round(cv.lifetime_value / cv.orders, 2) AS avg_order_valueFROM customer_value cvJOIN users u ON u.id = cv.user_idWHERE cv.lifetime_value > (SELECT avg(lifetime_value) FROM customer_value) -- CTE reusedORDER BY cv.lifetime_value DESC;
MATERIALIZED vs NOT MATERIALIZED (PG 12+)
Before PG 12: Every CTE was an optimization fence — computed once, stored, outer filters could not be pushed in
PG 12+ Default: A non-recursive, side-effect-free CTE referenced once is inlined like a subquery; referenced twice or more it is materialized
MATERIALIZED: Force compute-once (useful to stop the planner re-running an expensive CTE, or as a deliberate fence)
NOT MATERIALIZED: Force inlining even when referenced multiple times (lets WHERE id = … use an index inside)
Option
Computed
Outer Filters Pushed Down
Use When
Default (1 reference)
Inlined
Yes
Normal case
Default (2+ references)
Once
No
Expensive CTE reused
MATERIALIZED
Once
No
Expensive volatile work, or planner misestimates
NOT MATERIALIZED
Per reference
Yes
Cheap CTE + selective outer filter
-- Inlined: plan is just "Seq Scan on products, Filter: (id = 3)" — the filter reaches the table,-- so on a large table it becomes an Index Scan using products_pkeyEXPLAIN (COSTS OFF)WITH p AS NOT MATERIALIZED (SELECT * FROM products)SELECT * FROM p WHERE id = 3;-- Fenced: plan shows "CTE Scan on p, Filter: (id = 3)" over a full scan of products — no index possibleEXPLAIN (COSTS OFF)WITH p AS MATERIALIZED (SELECT * FROM products)SELECT * FROM p WHERE id = 3;
Recursive CTEs
Structure
WITH RECURSIVE name AS ( anchor query -- starting rows (e.g. the root) UNION ALL recursive query -- joins the table to "name" (rows from the previous step))SELECT … FROM name;Execution: run anchor → working table; repeat recursive part on the working table until it returns no rows; UNION ALL all iterations together
UNION vs UNION ALL: UNION removes duplicates each iteration (prevents cycles on graphs, but costs a hash); UNION ALL is faster for trees
Cycle Safety: Track a path array and stop when a node repeats, or use PG 14’s CYCLE clause
Depth Limit: Add a depth column and WHERE depth < 20 as a safety net
Example: Org Chart (Top-Down)
WITH RECURSIVE org AS ( SELECT id, name, manager_id, 1 AS level, name::text AS chain FROM employees WHERE manager_id IS NULL -- anchor: the CEO UNION ALL SELECT e.id, e.name, e.manager_id, org.level + 1, org.chain || ' > ' || e.name FROM employees e JOIN org ON e.manager_id = org.id -- step: direct reports of the previous level)SELECT repeat(' ', level - 1) || name AS org_chart, level, chainFROM orgORDER BY chain;
Example: Everyone Under a Manager (Subtree) and Total Salary
WITH RECURSIVE reports AS ( SELECT id, name, salary FROM employees WHERE name = 'Raj' UNION ALL SELECT e.id, e.name, e.salary FROM employees e JOIN reports r ON e.manager_id = r.id)SELECT count(*) - 1 AS people_under_raj, sum(salary) AS team_cost_incl_rajFROM reports;
Example: Path to Root (Bottom-Up) for a Category
WITH RECURSIVE ancestors AS ( SELECT id, name, parent_id, 0 AS up FROM categories WHERE name = 'Laptops' UNION ALL SELECT c.id, c.name, c.parent_id, a.up + 1 FROM categories c JOIN ancestors a ON c.id = a.parent_id)SELECT string_agg(name, ' > ' ORDER BY up DESC) AS breadcrumb FROM ancestors;-- Electronics > Computers > Laptops
Example: Category Tree with Cycle Protection (PG 14+ SEARCH / CYCLE)
WITH RECURSIVE tree AS ( SELECT id, name, parent_id FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.name, c.parent_id FROM categories c JOIN tree t ON c.parent_id = t.id) SEARCH DEPTH FIRST BY name SET ord CYCLE id SET is_cycle USING pathSELECT name, parent_id, path FROM tree WHERE NOT is_cycle ORDER BY ord;
Example: Generate a Series Without generate_series
WITH RECURSIVE days AS ( SELECT date '2025-01-01' AS d UNION ALL SELECT d + 1 FROM days WHERE d < date '2025-01-07')SELECT d, to_char(d, 'Dy') AS weekday FROM days;