queries & joins
Subqueries, CTEs & Window Functions
Express multi-step queries cleanly with CTEs (WITH), per-row computations with window functions, and lookups with subqueries.
Three tools handle queries that need more than a filter and a join. They overlap, and picking the right one is mostly about readability and one real capability difference.
CTE = "let me define X first, then build with it." Window function = "for each row, look at the surrounding crowd and compute something."
Key Concepts
1
A subquery is a query inside a query. It can sit in WHERE, in FROM as a derived table, or in the
SELECT list.
WHEREFROMSELECT
2
The kind to watch is the correlated subquery — one that references the outer row, so it runs once per
row:
3
SELECT c.name,
(SELECT COUNT(*) FROM orders o
WHERE o.customer_id = c.id) -- runs per customer
FROM customers c4
A million customers means a million executions. Often rewritable as a single join or window function.
5
A CTE names a step. WITH name AS (...) puts the subquery up front so the main query reads it like a
table:
WITH name AS (...)
6
WITH recent AS (
SELECT * FROM orders WHERE created_at > '2026-01-01'
), totals AS (
SELECT customer_id, SUM(total) AS spend FROM recent GROUP BY customer_id
)
SELECT * FROM totals WHERE spend > 100007
Same result as nesting three levels deep, read top to bottom instead of inside out.
8
CTEs also do something nothing else can: recursion, for hierarchies like an org chart or a category
tree, where you cannot know in advance how many levels there are.
9
Window functions keep every row. This is the difference that matters. GROUP BY collapses a group into
one row. A window function computes across the group and leaves the rows alone:
GROUP BY
10
name dept salary AVG(salary) OVER (PARTITION BY dept)
Asha eng 120 110
Ravi eng 100 110
Meera sales 90 9011
Six values in, six rows out, each carrying its department average. With GROUP BY dept you would get two
rows and lose every name.
GROUP BY dept
12
That is why window functions solve, in one line, problems that otherwise need a self-join:
13
- `ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC)` — rank within a group
- `SUM(x) OVER (ORDER BY date)` — running total
- `LAG(price) OVER (ORDER BY date)` — compare a row to the previous oneSUM(x) OVER (ORDER BY date)LAG(price) OVER (ORDER BY date)
14
Two practical cautions. A CTE is not always free — in some databases it is an optimisation fence,
materialised rather than folded into the main query, so on a hot path check the plan rather than assuming.
And filtering on a window function needs a wrapper, because WHERE runs before the window is computed:
WHERE
15
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn
FROM employees
) t WHERE rn <= 316
What the interviewer is probing.1. "When is a window function better than a self-join?" Probing: the fit. Stalls: "Never."
Moves up: for ranking, running totals and comparisons to neighbouring rows — it computes per row
without collapsing or re-reading the table.
17
2. "What is the difference between a CTE and a subquery?" Probing: the practical answer.
Stalls: "CTEs are faster." Moves up: mainly readability and reuse — though some engines
materialise a CTE, which can help or hurt, and recursion requires one.
18
3. "What does a correlated subquery cost?" Probing: the hazard. Stalls: "Nothing." *Moves
up:* it may execute per outer row, so it behaves like a loop — a join or window function is usually
far cheaper.
19
4. "What is the difference between ROW_NUMBER, RANK and DENSE_RANK?" Probing: the detail.
Stalls: "The same." Moves up: ROW_NUMBER is always unique; RANK leaves gaps after ties;
DENSE_RANK does not.