Week 4: Subqueries, CTEs & Window Functions

Joins combine tables side by side. This week adds three more ways to structure a query — nesting one query inside another, naming a subquery for readability with a CTE, and window functions, which compute a value across a set of related rows without collapsing them the way GROUP BY does.

Module 4 of 10 Week 4 of 10 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Use a subquery inside WHERE and FROM
  • Write a Common Table Expression with WITH, and explain why it's often clearer than a nested subquery
  • Use ROW_NUMBER and RANK to number or rank rows within groups
  • Compute a running total or moving average with a window function

1. Subqueries in WHERE

a subquery inside WHERE
-- customers whose average order total is above the overall average
SELECT name FROM customers
WHERE id IN (
    SELECT customer_id FROM orders
    GROUP BY customer_id
    HAVING AVG(total) > (SELECT AVG(total) FROM orders)
);

The inner query runs first (conceptually), producing a set of values the outer query's WHERE ... IN (...) then filters against. This particular query nests two levels deep — an aggregate subquery for the overall average, inside a grouped subquery for per-customer averages, inside the outer filter — which is exactly the kind of query that gets hard to read past two or three levels, and exactly what CTEs (next) are for.

2. Subqueries in FROM & CTEs

a subquery in FROM — treating a query's result as a table
SELECT customer_totals.name, customer_totals.total_spent
FROM (
    SELECT customers.name, SUM(orders.total) AS total_spent
    FROM customers
    JOIN orders ON customers.id = orders.customer_id
    GROUP BY customers.name
) AS customer_totals
WHERE customer_totals.total_spent > 100;
the same query, as a CTE — genuinely more readable
WITH customer_totals AS (
    SELECT customers.name, SUM(orders.total) AS total_spent
    FROM customers
    JOIN orders ON customers.id = orders.customer_id
    GROUP BY customers.name
)
SELECT name, total_spent
FROM customer_totals
WHERE total_spent > 100;

A CTE (Common Table Expression), introduced with WITH, is functionally identical to a subquery in FROM — the difference is purely readability: it's named, appears before the main query instead of nested inside it, and multiple CTEs can chain together, each referencing the ones before it, which reads top-to-bottom instead of inside-out.

3. Window Functions: ROW_NUMBER & RANK

A window function computes a value across a set of related rows — like an aggregate — but without collapsing them into one row per group the way GROUP BY does. Every original row stays in the result, with the computed value attached.

numbering each customer's orders, most recent first
SELECT
    customer_id,
    id AS order_id,
    total,
    ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY id DESC) AS order_rank
FROM orders;

PARTITION BY is GROUP BY's window-function equivalent — it splits rows into groups for the function to operate within — but unlike GROUP BY, every row from every partition still appears in the output. RANK() works the same way as ROW_NUMBER() but assigns the same rank to tied rows (and skips the next number), where ROW_NUMBER() always assigns a strictly increasing, unique number regardless of ties.

4. Running Totals & Moving Averages

a running total of revenue, ordered by date
SELECT
    created_at,
    total,
    SUM(total) OVER (ORDER BY created_at) AS running_total
FROM orders
ORDER BY created_at;

With no PARTITION BY, the window spans the whole result set — each row's running_total is the sum of every total up to and including that row's position in the ORDER BY. Aggregate functions (SUM, AVG, COUNT) become window functions simply by adding OVER (...) — the same functions from Week 2, used differently.

5. Hands-on Exercise

Hands-on

Build a leaderboard and a revenue trend report

Apply CTEs and window functions to the orders data from earlier weeks.

Requirements:

  1. A CTE computing total revenue per customer, then a main query selecting only customers above a chosen revenue threshold — rewriting an equivalent nested subquery version first, then the CTE version, and comparing readability in a comment.
  2. A query using RANK() OVER (ORDER BY total_spent DESC) to produce a customer leaderboard by total spend.
  3. A query using ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) to number each customer's orders chronologically (their 1st order, 2nd order, and so on).
  4. A query computing a running total of daily revenue using SUM(...) OVER (ORDER BY created_at).
  5. A query using ROW_NUMBER() with a CTE to find each customer's single most recent order — a genuinely common real-world pattern.
Hint

If a query using ROW_NUMBER() to find "each customer's most recent order" returns more than one row per customer, remember ROW_NUMBER() itself doesn't filter anything — it only numbers rows. Wrap it in a CTE, then filter the outer query for WHERE row_num = 1; you cannot filter on a window function's result directly in the same query's WHERE clause, since WHERE runs before window functions are computed.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What is the actual difference between a subquery nested in FROM and an equivalent CTE defined with WITH?

None, functionally — a CTE is evaluated the same way and produces the same result as an equivalent FROM subquery. The difference is entirely about readability and structure: a CTE is named and defined before the main query rather than nested inside it, and multiple CTEs can chain together in a way that reads top-to-bottom instead of requiring the reader to parse inside-out.

Q2

How does PARTITION BY in a window function differ from GROUP BY, given both split rows into groups?

GROUP BY collapses each group into a single summary row, discarding the individual rows that made it up. PARTITION BY defines groups for a window function to compute within, but every original row remains in the output — the computed value (a rank, a running total) is simply attached to each row rather than replacing the rows with one aggregate per group.

Q3

Why can RANK() assign the number 1 to two different rows, followed immediately by 3 (skipping 2), while ROW_NUMBER() never does this?

RANK() assigns the same rank to rows that tie on the ORDER BY criteria, and then skips ahead by however many rows tied — two rows tied for first both get 1, and the next distinct row gets 3, reflecting that two rows occupied positions 1 and 2. ROW_NUMBER() ignores ties entirely and always assigns a strictly sequential, unique number to every row regardless of whether values are equal.

Q4

Why can't a window function's result be filtered directly in the same query's WHERE clause?

WHERE is evaluated before window functions are computed, as part of SQL's logical execution order — at the point WHERE runs, no window function result exists yet to filter on. Filtering on a window function's output requires wrapping it in a subquery or CTE first, computing the window function there, and then filtering in an outer query against that already-computed column.