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:
-- 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:
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:
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:
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:
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:
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.
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
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:
- Add enough rows to
ordersto cover at least 3 different months (reuse Week 6'scategorycolumn). - Write a subquery-based query returning every order priced above the overall average
amount. - Write a
WITH monthly_sales AS (...)CTE that totalsamountper month, then a second chained CTE that ranks those months withRANK(), and a finalSELECTreturning only the top 2 months. - On the same
monthly_salesCTE, add a running total column usingSUM(...) OVER (ORDER BY month). - Add
LAGandLEADcolumns showing each month's total next to the previous and next month's total, plus a computedchange_from_prevcolumn.
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?
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?
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?
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?
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.