Subqueries & CTEs
Definition
A subquery is a query nested inside another query. A CTE (Common Table Expression), written with
WITH, is a named, reusable subquery that can make complex queries far more readable - and, uniquely, can also be recursive.
Subqueries in WHERE
-- Scalar subquery: returns a single value
SELECT * FROM products WHERE price > (SELECT AVG(price) FROM products);
-- IN subquery: returns a list of values
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- NOT IN subquery
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
NOT INwith a subquery that can returnNULLis a classic trapIf the subquery returns even one
NULL,NOT INreturns zero rows for the entire outer query - becausex <> NULLis unknown, not true, for every comparison. UseNOT EXISTSinstead (see below), which doesn’t have this problem.
EXISTS / NOT EXISTS
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id); -- users with NO orders
EXISTSvsIN
EXISTSstops scanning as soon as one matching row is found (a boolean check), and correctly handlesNULLs in the subquery. For correlated existence checks,EXISTS/NOT EXISTSis generally the safer, more efficient choice overIN/NOT IN.
Subqueries in SELECT (Scalar Subquery as a Column)
SELECT
name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count
FROM users u;Correlated subqueries in
SELECTrun once per outer rowThis pattern is easy to write but can be slow on large tables since Postgres re-executes the inner query for every outer row. A
LEFT JOIN+GROUP BY(see 08-Aggregation-GroupBy) is often faster for the same result.
Subqueries in FROM (Derived Tables)
SELECT city, avg_age
FROM (
SELECT city, AVG(age) AS avg_age
FROM users
GROUP BY city
) AS city_stats
WHERE avg_age > 30;A subquery in
FROMmust always be given an alias (AS city_statsabove) - Postgres requires every derived table to have a name.
Common Table Expressions (WITH)
WITH active_users AS (
SELECT * FROM users WHERE is_active = true
)
SELECT city, COUNT(*)
FROM active_users
GROUP BY city;CTEs as readability tools
A CTE is functionally similar to a subquery in
FROM, but named and placed at the top of the query - this breaks a complex query into clearly-labeled logical steps, each of which can be tested independently by running it alone.
Multiple CTEs in One Query
WITH
active_users AS (
SELECT * FROM users WHERE is_active = true
),
big_spenders AS (
SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id HAVING SUM(amount) > 1000
)
SELECT au.name, bs.total
FROM active_users au
JOIN big_spenders bs ON bs.user_id = au.id;Recursive CTEs
WITH RECURSIVE org_chart AS (
-- Base case: the top of the hierarchy
SELECT id, name, manager_id, 1 AS depth
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive case: join back to the CTE itself
SELECT e.id, e.name, e.manager_id, oc.depth + 1
FROM employees e
JOIN org_chart oc ON e.manager_id = oc.id
)
SELECT * FROM org_chart ORDER BY depth;graph TD A["Base case (anchor query)"] --> B["Recursive case joins back to the CTE"] B --> C{"Any new rows produced?"} C -->|Yes| B C -->|No| D["Stop, return combined results"]
Classic use cases for recursive CTEs
Organizational hierarchies (employee -> manager chains), category trees (nested subcategories), graph traversal (e.g. “find all paths between two nodes”), and generating sequences (
generate_seriesis often simpler for pure number/date sequences though).
Recursive CTEs can infinite-loop
If the underlying data has a cycle (e.g. A manages B, B manages A) and there’s no depth/visited-check, the recursion never terminates. Add a safeguard:
WHERE depth < 20or track visited IDs in an array.
WITH ... AS MATERIALIZED / NOT MATERIALIZED (Postgres 12+)
WITH expensive_calc AS MATERIALIZED (
SELECT * FROM big_table WHERE complex_condition
)
SELECT * FROM expensive_calc WHERE another_condition;Why this matters
Since Postgres 12, CTEs are “inlined” (optimized like a subquery) by default unless marked
MATERIALIZED, which forces the CTE to be computed once and cached. UseMATERIALIZEDwhen the CTE is expensive and referenced multiple times; useNOT MATERIALIZEDto hint the planner to inline it for better optimization opportunities.