Week 7: Subqueries, CTEs & Window Functions

Everything through Week 6 answers "what's the total for this group?" This week answers harder questions: "which months rank in the top 2?", "what's the running total through this point?", "how does this month compare to last month?" — the SQL that separates pulling data from actually analyzing it, and the last stop in SQL before Power BI's own formula language, DAX, picks up many of these same ideas.

Module 7 of 12 Week 7 of 12 ~3–4 Hours Hands-on Exercise Included

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

  • Write a subquery, and chain multiple CTEs together for a multi-step analysis
  • Rank rows with ROW_NUMBER/RANK and compute a running total with OVER()
  • Compare a row to the one before or after it with LAG/LEAD

1. Subqueries: A Query Inside a Query

A subquery is a complete SELECT nested inside another query — used wherever a condition or a value needs to be computed from the data itself, rather than a fixed number typed into the query by hand:

sql
-- Orders priced above the OVERALL average -- the average has to be computed
-- from the same table, so it can't just be typed in as a literal number
SELECT order_id, amount
FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders);

-- A subquery can also stand in for a table, in the FROM clause
SELECT category, order_count
FROM (
  SELECT category, COUNT(*) AS order_count
  FROM orders
  GROUP BY category
) AS category_counts
WHERE order_count > 2;

The first example's subquery runs once, produces a single number (the average), and that number is substituted into the WHERE clause of the outer query — this shape is called a scalar subquery. The second wraps an entire grouped query in parentheses and treats its result as if it were a regular table, giving it an alias (category_counts) so the outer query can reference it. Every subquery needs that alias when used in FROM — SQL won't accept an unnamed derived table.

2. Chaining CTEs

Week 6 introduced a single WITH CTE as a readability tool. CTEs can also chain — a later CTE can reference an earlier one directly, turning a multi-step analysis into a sequence of clearly labeled stages instead of subqueries nested inside subqueries inside subqueries:

sql
WITH monthly_sales AS (
  -- Step 1: collapse every order down to one total per month
  -- (DATE_TRUNC is PostgreSQL; MySQL's equivalent is DATE_FORMAT(order_date, '%Y-%m-01'))
  SELECT DATE_TRUNC('month', order_date) AS month, SUM(amount) AS total_amount
  FROM orders
  GROUP BY DATE_TRUNC('month', order_date)
),
ranked_months AS (
  -- Step 2: rank those monthly totals, using the CTE from Step 1 as a normal table
  SELECT month, total_amount,
         RANK() OVER (ORDER BY total_amount DESC) AS sales_rank
  FROM monthly_sales
)
-- Step 3: the final query just filters the already-ranked result
SELECT * FROM ranked_months WHERE sales_rank <= 2;

Each AS (...) block is a named, self-contained step — ranked_months reads from monthly_sales exactly as if it were a real table, even though neither one is ever actually written to the database. This is the single biggest structural upgrade for a query past a certain complexity: instead of one dense block doing everything at once, the logic reads top to bottom as a sequence of named stages, each easy to test on its own by temporarily changing the final SELECT to read from an earlier stage.

3. ROW_NUMBER & RANK

Both assign a position to each row based on an ordering — the difference is entirely about how they handle ties:

sql
SELECT month, total_amount,
       ROW_NUMBER() OVER (ORDER BY total_amount DESC) AS row_num,
       RANK()       OVER (ORDER BY total_amount DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY total_amount DESC) AS dense_rnk
FROM monthly_sales;

-- If two months tie for the highest total_amount:
-- row_num:    1, 2, 3, 4   -- always unique, ties broken arbitrarily
-- rnk:        1, 1, 3, 4   -- ties share a rank, then SKIPS to 3 (2 is never used)
-- dense_rnk:  1, 1, 2, 3   -- ties share a rank, next rank is NOT skipped

OVER (ORDER BY total_amount DESC) is what makes these window functions rather than regular aggregates — instead of collapsing rows together like GROUP BY does, a window function calculates a value per row while still being aware of the other rows around it (here, "everything ordered by total, descending"). ROW_NUMBER is the right choice whenever a genuinely unique position is needed regardless of ties (like picking exactly one "top" row); RANK or DENSE_RANK is right whenever ties should be reported as tied.

4. Running Totals With OVER()

The same OVER() syntax turns a normal aggregate into a running one — recomputed at each row instead of once for the whole result:

sql
SELECT month, total_amount,
       SUM(total_amount) OVER (ORDER BY month) AS running_total
FROM monthly_sales;

-- month     total_amount   running_total
-- 2026-01   2390           2390
-- 2026-02   3150           5540   -- 2390 + 3150
-- 2026-03   1720           7260   -- 5540 + 1720

Without OVER(), SUM(total_amount) would collapse all three rows into a single number — OVER (ORDER BY month) instead tells it "sum everything from the start up through the current row," recalculated fresh at every row. Adding PARTITION BY resets that running calculation separately within each group:

sql -- a running total that restarts per category
SELECT category, order_date, amount,
       SUM(amount) OVER (PARTITION BY category ORDER BY order_date) AS category_running_total
FROM orders;

-- Electronics gets its own running total, Clothing gets its own separate one --
-- PARTITION BY splits the window into independent groups, the same way
-- GROUP BY splits rows into groups, just without collapsing them into one row each

5. LAG & LEAD: Comparing a Row to Its Neighbor

LAG and LEAD pull a value from a different row — the previous one or the next one, in a given order — directly into the current row, which is exactly what a month-over-month comparison needs:

sql
SELECT month, total_amount,
       LAG(total_amount)  OVER (ORDER BY month) AS prev_month_amount,
       total_amount - LAG(total_amount) OVER (ORDER BY month) AS change_from_prev,
       LEAD(total_amount) OVER (ORDER BY month) AS next_month_amount
FROM monthly_sales;

-- month     total_amount   prev_month_amount   change_from_prev   next_month_amount
-- 2026-01   2390           NULL                NULL               3150
-- 2026-02   3150           2390                760                1720
-- 2026-03   1720           3150                -1430              NULL

LAG looks backward (the previous row's value), LEAD looks forward (the next row's value) — both return NULL at the boundary where no such row exists (there's no month before January, no month after March in this data). Subtracting LAG(...) from the current row's value in the same query is the standard pattern for "change since last period," computed directly in SQL instead of needing a separate calculation step afterward.

This is the same shape DAX's time-intelligence measures use

Week 10's Power BI module covers DAX measures like month-over-month and year-over-year growth — under the hood, they're solving exactly this "compare this row to a different, related row" problem. Being comfortable with LAG/LEAD here makes that later module click much faster.

6. Hands-on Exercise

Hands-on

Build a monthly sales trend report

Extend Week 6's orders table with a few more months of data, then analyze the trend with subqueries and window functions.

Requirements:

  1. Add enough rows to orders to cover at least 3 different months (reuse Week 6's category column).
  2. Write a subquery-based query returning every order priced above the overall average amount.
  3. Write a WITH monthly_sales AS (...) CTE that totals amount per month, then a second chained CTE that ranks those months with RANK(), and a final SELECT returning only the top 2 months.
  4. On the same monthly_sales CTE, add a running total column using SUM(...) OVER (ORDER BY month).
  5. Add LAG and LEAD columns showing each month's total next to the previous and next month's total, plus a computed change_from_prev column.
Hint

If your SQL environment doesn't support DATE_TRUNC, group by whatever month-extraction function it does support (strftime('%Y-%m', order_date) in SQLite, DATE_FORMAT(order_date, '%Y-%m') in MySQL) — the CTE and window-function logic around it stays identical either way.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can't WHERE amount > (SELECT AVG(amount) FROM orders) be rewritten as WHERE amount > AVG(amount) directly?

WHERE filters individual rows before any aggregation happens (the same rule from Week 2's HAVING discussion) — an aggregate like AVG() can't be evaluated per row inside WHERE. The subquery sidesteps this entirely: it runs as its own separate, complete query, computes the average once, and the outer query's WHERE just compares each row against that already-computed number.

Q2

In a chained CTE like WITH monthly_sales AS (...), ranked_months AS (...), can ranked_months reference monthly_sales?

Yes — that's the entire point of chaining CTEs. Each CTE defined earlier in the same WITH clause is available to any CTE (or the final query) defined after it, and can be read exactly like a real table. This is what turns a multi-step analysis into a readable sequence of named stages instead of one deeply nested query.

Q3

Two months tie for the highest total_amount. What ranks would ROW_NUMBER give them, and what ranks would RANK give them?

ROW_NUMBER() would give them two different, arbitrarily-broken-tie numbers (like 1 and 2) — it never repeats a value. RANK() would give both of them rank 1, then skip straight to rank 3 for the next row, since two rows have already claimed positions 1 and 2 worth of ranking.

Q4

What does LAG(total_amount) OVER (ORDER BY month) return for the very first row, ordered by month?

NULL. LAG pulls the value from the previous row in the specified order, and the first row (by definition) has no row before it — the same boundary behavior LEAD has at the last row, looking forward. Any calculation built on top of LAG, like a month-over-month change, will also be NULL for that first row.