Subqueries, CTEs and window functions

Scalar, correlated and derived subqueries, WITH and recursive CTEs, and the ROW_NUMBER/RANK/LAG family that ranks and compares rows without collapsing them.

Subqueries in four positions

A subquery is a SELECT used as a value, as a filter, or as a table. Where you put it determines what it may return: one row, one column, or a full result set.

-- scalar subquery: one row, one column, usable as an expression
SELECT name,
       (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS order_count
FROM customers c;

-- existence test: stops at the first matching row
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

-- anti-join: customers with nothing
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);

-- derived table: a subquery you can join and filter
SELECT t.customer_id, t.total
FROM (
  SELECT customer_id, SUM(total) AS total
  FROM orders
  GROUP BY customer_id
) AS t
WHERE t.total > 1000;
PositionShapeNote
SELECT list(SELECT max(x) FROM t)Must return exactly one row and one column, or the statement errors
WHERE / HAVINGIN (...), EXISTS (...)EXISTS handles NULL and large sets better than IN
FROM / JOIN(SELECT ...) AS aliasNeeds an alias in every dialect
Comparison= (SELECT ...)Fails at runtime if more than one row comes back
CorrelatedReferences the outer queryRuns per outer row in principle — check the plan before trusting it

CTEs and recursion

WITH monthly AS (
  SELECT date_trunc('month', placed_at) AS month,
         SUM(total) AS revenue
  FROM orders
  GROUP BY 1
),
growth AS (
  SELECT month,
         revenue,
         LAG(revenue) OVER (ORDER BY month) AS prev_revenue
  FROM monthly
)
SELECT month, revenue, revenue - prev_revenue AS change
FROM growth
ORDER BY month;

-- recursive: walk up a chain of managers
WITH RECURSIVE chain AS (
  SELECT id, manager_id, name, 1 AS depth
  FROM employees
  WHERE id = 42
  UNION ALL
  SELECT e.id, e.manager_id, e.name, c.depth + 1
  FROM employees e
  JOIN chain c ON e.id = c.manager_id
)
SELECT depth, name FROM chain ORDER BY depth;
  • A CTE is a named building block: later CTEs may reference earlier ones, and the final SELECT reads like a sequence of steps.
  • Name CTEs after the thing they contain (monthly, active_customers), not after their position (cte1).
  • WITH RECURSIVE needs three parts: a base case, a step that consumes the previous result, and a condition that eventually stops producing rows.
  • Push filters into the first CTE when you can — fewer rows flowing through every later step is the cheapest optimisation available.
⚠️
A recursive CTE whose recursive part never shrinks its input runs until the engine's recursion limit or until you kill the query. Every recursive step must move strictly toward a terminating state — toward the root of a tree, one level up in a hierarchy, never sideways.

Window functions

An aggregate collapses rows into groups; a window function keeps every row and adds a value computed over a window of related rows. That is what makes ranking, running totals and row-to-row comparisons ordinary SQL.

SELECT
  order_id,
  customer_id,
  total,
  ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY placed_at DESC) AS seq,
  RANK()       OVER (ORDER BY total DESC)                              AS rank_all,
  LAG(total)   OVER (PARTITION BY customer_id ORDER BY placed_at)      AS prev_total,
  SUM(total)   OVER (PARTITION BY customer_id)                         AS customer_total,
  AVG(total)   OVER (ORDER BY placed_at
                     ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)         AS moving_avg_3
FROM orders;

-- the top order per customer: filter outside the window
WITH ranked AS (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY total DESC) AS seq
  FROM orders
)
SELECT * FROM ranked WHERE seq = 1;
FunctionReturns
ROW_NUMBER()1, 2, 3 — unique per partition, ties broken arbitrarily
RANK()Ties share a rank and the next rank skips: 1, 1, 3
DENSE_RANK()Ties share a rank with no gaps: 1, 1, 2
LAG() / LEAD()The previous or next row's value
FIRST_VALUE() / LAST_VALUE()Edges of the window frame
SUM() OVERRunning or partitioned total that keeps every row
NTILE(n)Splits the partition into n roughly equal buckets
  • Windows are evaluated after WHERE and GROUP BY but before ORDER BY and LIMIT, so a window result cannot be filtered in the same query level.
  • The default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW once you add ORDER BY — write the frame out when you mean ROWS.
  • PARTITION BY restarts the calculation per group; forgetting it produces one global running total across unrelated entities.
  • An index matching the PARTITION BY and ORDER BY columns lets the engine avoid a sort.

FAQ

EXISTS or IN?
EXISTS when the subquery could return NULL, when the set is large, or when you only need existence — it can stop at the first match. IN reads better for a short literal list. For negatives, always prefer NOT EXISTS over NOT IN.
Why can't I filter on ROW_NUMBER() in WHERE?
Window functions are computed after WHERE runs. Put the windowed query in a CTE or derived table and filter the result: SELECT * FROM ranked WHERE seq = 1.

Data types, NULL and three-valued logic Query tuning and execution plans

Last refreshed 2026-09-18.