learn-sql-with-phoebe / Builder session 7 of 10
Learn SQL with Phoebe · Builder track · Session 7 of 10

Window functions

This is the session that separates people who "know SQL" from people who reach for SQL first. A window function computes across a set of rows - a rank, a running total, last month's value - while keeping every row on screen. No collapsing, no self-joins, no subquery gymnastics. Same Daybreak database, one keyword that unlocks analytics you used to export to a spreadsheet.

🟠 Builder track Practitioners: analysts · DE · DS · PMs Runs in your browser · SQLite 45 min
0-3 · Welcome 3-24 · Rank, offset, running 24-42 · Build-along 42-45 · Q&A
Part 0

Where this session fits

In b3 you learned GROUP BY - it collapses many rows into one summary per group. Powerful, but it throws the detail away. A window function does the opposite: it computes a group-level number and keeps every row, adding the result as a new column. That is how you rank products by price without losing the products, or show a running revenue total next to each month. The keyword is OVER, and once it clicks, you will use it constantly.

Live - presented in session Self-study - read after class ▶ Live SQL - editable & runnable Official sources covered
★ What you walk out with today The OVER (...) mental model, the ranking family (ROW_NUMBER, RANK, DENSE_RANK) with PARTITION BY, and the offset/running-total pair (LAG/LEAD and SUM() OVER) - the exact tools that make month-over-month and top-N-per-group trivial.
Part 1 · covers Mode Advanced "SQL Window Functions", SQLZoo "Window function"

A window aggregates without collapsing rows 7 min live

Here is the whole idea in one sentence: GROUP BY gives you one row per group; a window function gives you every row plus a group-level column. The OVER (...) clause defines the "window" - the set of rows the function looks at - while your row stays right where it is. That is why you can show each product's price and its rank in the same result.

GROUP BY collapses 3 rows become 1 summary one row: SUM = 630 Detail is gone. You get the total, not the rows behind it. Window keeps every row + adds a column product row 1 rank 1 product row 2 rank 2 product row 3 rank 3 OVER (...) Every row survives. The new column is computed over the window you define. Rule of thumb: need one row per group, use GROUP BY. Need the detail plus a group number, use OVER.
🔍 Click to zoom - GROUP BY collapses; a window function keeps every row and adds a computed column
LiveRank every product by price - rows intact4 min

RANK() OVER (ORDER BY price DESC) assigns 1 to the most expensive product, 2 to the next, and so on - while every product row stays on screen. The OVER (ORDER BY ...) is the window: "look at all rows, ordered by price, and rank me within that". Run it and read the new price_rank column.

SELECT name, price,
       RANK() OVER (ORDER BY price DESC) AS price_rank
FROM products;
The two parts of every window function A function (RANK, SUM, LAG...) and an OVER (...) that defines which rows it sees. Inside OVER you can put ORDER BY (sequence), PARTITION BY (groups), or both. Master those two words and the whole family opens up.
Self-studyThe table-wide total on every row2 min read

SUM(price) OVER () with an empty window sums across the whole table and puts that total on every row - handy for "this price as a share of the catalog total" without a subquery.

SELECT name, price,
       SUM(price) OVER () AS catalog_total
FROM products;

This is the window twin of the scalar subquery you saw in b6 - same result, and it reads more naturally once the window habit sets in.

Part 2 · covers Mode Advanced "SQL Window Functions", SQLZoo "Window function"

The ranking family + PARTITION BY 7 min live

Three ranking functions, one small but important difference. ROW_NUMBER gives a unique number to every row (1,2,3,4). RANK gives ties the same number then skips (1,1,3). DENSE_RANK gives ties the same number without skipping (1,1,2). And PARTITION BY restarts the ranking within each group - "rank customers within each plan", "top product per category".

LiveRank customers by total spend4 min

A CTE from b6 does the heavy lifting - spend sums each customer's completed revenue. Then RANK() OVER (ORDER BY s.total DESC) ranks them. Run it: Noah Park (232) is rank 1, Ava Chen (222) rank 2, Ethan Ruiz (177) rank 3. The window did the ranking; the rows stayed intact.

Ranking customers by spend keeps every row - RANK adds a column Noah Park - rank 1 232 Ava Chen - rank 2 222 Ethan Ruiz - rank 3 177 Noah Park (232), Ava Chen (222) and Ethan Ruiz (177) each keep their own row - RANK just adds a number.
🔍 Click to zoom - RANK adds a column, it never removes a row
WITH spend AS (
  SELECT o.customer_id, SUM(oi.quantity*oi.unit_price) AS total
  FROM orders o
  JOIN order_items oi ON o.order_id=oi.order_id
  WHERE o.status='completed'
  GROUP BY o.customer_id
)
SELECT c.name, ROUND(s.total,2) AS total,
       RANK() OVER (ORDER BY s.total DESC) AS rnk
FROM spend s
JOIN customers c ON c.customer_id=s.customer_id
LIMIT 5;
Real world

Top-N-per-group, the interview classic. "Give me the top 2 customers in each plan" is a question that ties people in knots with subqueries - but it is one RANK() OVER (PARTITION BY plan ORDER BY total DESC) plus a WHERE rnk <= 2 wrapper. Once you have this pattern, a whole category of "hardest" SQL questions becomes routine.

Self-studyROW_NUMBER vs RANK vs DENSE_RANK2 min read

Rank products by price and watch how the three functions handle ties differently. If two products share a price, ROW_NUMBER still gives them distinct numbers, RANK ties them and skips the next, DENSE_RANK ties them and does not skip.

SELECT name, price,
       ROW_NUMBER() OVER (ORDER BY price DESC) AS row_num,
       RANK()       OVER (ORDER BY price DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY price DESC) AS dense
FROM products;

Pick by intent: ROW_NUMBER when you need a unique sequence, RANK when gaps after ties are meaningful (like leaderboard places), DENSE_RANK when you want consecutive tiers.

Part 3 · covers Mode Advanced "SQL Window Functions", Kaggle "Analytic Functions"

Offsets and running totals: LAG/LEAD and SUM() OVER 8 min live

The last two tools are the ones analysts reach for weekly. SUM(x) OVER (ORDER BY ...) builds a running total - each row adds itself to everything before it. LAG(x) reaches back to the previous row's value (and LEAD forward), which is exactly what month-over-month change needs: this month minus last month.

LiveRunning monthly revenue4 min

First a CTE builds monthly revenue with substr(order_date,1,7) as the month key (YYYY-MM). Then SUM(rev) OVER (ORDER BY mo) accumulates it - January stands alone, February adds January, and so on. Run it and watch the running column climb.

WITH m AS (
  SELECT substr(o.order_date,1,7) AS mo,
         SUM(oi.quantity*oi.unit_price) AS rev
  FROM orders o
  JOIN order_items oi ON o.order_id=oi.order_id
  WHERE o.status='completed'
  GROUP BY mo
)
SELECT mo, rev,
       SUM(rev) OVER (ORDER BY mo) AS running
FROM m
ORDER BY mo;
ORDER BY inside OVER is the sequence For a running total, the ORDER BY inside OVER defines the order rows accumulate in. Change it and you change what "running" means. It is separate from the query's outer ORDER BY, which only sorts the final display.
LiveMonth-over-month change with LAG4 min

LAG(rev) OVER (ORDER BY mo) pulls the previous month's revenue onto the current row, so rev - LAG(rev) is the month-over-month change. Run it: January's change is blank (no prior month), and you will spot a clear dip when March drops from February.

WITH m AS (
  SELECT substr(o.order_date,1,7) AS mo,
         SUM(oi.quantity*oi.unit_price) AS rev
  FROM orders o
  JOIN order_items oi ON o.order_id=oi.order_id
  WHERE o.status='completed'
  GROUP BY mo
)
SELECT mo, rev,
       rev - LAG(rev) OVER (ORDER BY mo) AS change
FROM m
ORDER BY mo;

January's change is NULL because there is no earlier row for LAG to reach - the expected, correct behavior at the edge of a window.

Build-along

Read the running total and spot the March dip ★ 16 min · everyone builds

Let us put the two Part 3 queries side by side and actually read them like an analyst. The verified monthly completed revenue is Jan 209, Feb 271, Mar 150, Apr 239, May 183, Jun 168. Your job: run both, then narrate what the change column is telling you - because that March dip is the mystery session b9 investigates end to end.

The same LAG window slides across every ordered month +62 -121 Jan Feb Mar Apr May Jun 209 271 150 239 183 168 LAG reaches back one row: Feb rises +62 over Jan, then Mar falls -121, the sharpest drop yet.
🔍 Click to zoom - the same LAG window slides month to month; March's drop is the sharpest

Run the running-total query. Confirm the running column only ever climbs - a running total never goes down when revenue is positive.

Run the LAG query. Line up the change column against the monthly numbers in your head: Feb is +62 over Jan, then March swings hard negative.

Find the biggest drop: February 271 to March 150 is a change of -121 - the largest single-month fall in the whole series.

Ask the b9 question: is that a real business event (a bad month) or a data artifact (refunds, a channel outage)? Window functions found the dip; b9 explains it.

WITH m AS (
  SELECT substr(o.order_date,1,7) AS mo,
         SUM(oi.quantity*oi.unit_price) AS rev
  FROM orders o
  JOIN order_items oi ON o.order_id=oi.order_id
  WHERE o.status='completed'
  GROUP BY mo
)
SELECT mo, rev,
       SUM(rev) OVER (ORDER BY mo) AS running,
       rev - LAG(rev) OVER (ORDER BY mo) AS change
FROM m
ORDER BY mo;
Real world

This is how anomalies get found. Nobody scrolls a revenue table looking for trouble. They compute a month-over-month change column and let the big negative number jump out. Window functions turn "stare at the data" into "the data tells you where to look" - which is exactly the analyst instinct b9 builds on.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Official sources covered

This session teaches the window-function core of the major free SQL curricula, run on live Daybreak data instead of screenshots. Graded problem sets and certificates stay on the official sites (linked below). This page covers:

Mode SQL Tutorial - Advanced: SQL Window FunctionsParts 1-3 · OVER, ranking family, running totals and LAG, run live
SQLZoo - Window functionParts 1-2 · OVER and the ranking family with PARTITION BY
Kaggle - Advanced SQL: Analytic FunctionsPart 3 · running totals and LAG/LEAD offsets; Kaggle's exercises stay on Kaggle
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · How does a window function differ from GROUP BY?

GROUP BY collapses many rows into one summary per group. A window function computes a group-level value but keeps every original row, adding the result as a new column.

2 · What does PARTITION BY do inside OVER (...)?

PARTITION BY splits rows into groups and restarts the window function within each - so a rank counts from 1 again for every category, plan, or customer.

3 · What does LAG(rev) OVER (ORDER BY mo) give you?

LAG reaches back to the prior row's value along the OVER ordering, so rev - LAG(rev) gives the change from the previous month. LEAD reaches forward instead.

Builder session 7 cheat sheet · pin this

Window functionA function + OVER (...). Computes across a set of rows but keeps every row on screen.
OVER (...)Defines the window. Put ORDER BY (sequence), PARTITION BY (groups), or both inside.
vs GROUP BYGROUP BY collapses to one row per group. A window keeps detail and adds a column.
Ranking familyROW_NUMBER (unique 1,2,3), RANK (ties skip: 1,1,3), DENSE_RANK (ties, no skip: 1,1,2).
PARTITION BYRestarts the calculation per group. Top-N-per-group = RANK ... PARTITION BY + WHERE rnk<=N.
Running totalSUM(x) OVER (ORDER BY ...) accumulates each row with everything before it.
LAG / LEADLAG(x) = previous row's value, LEAD(x) = next. rev - LAG(rev) = month-over-month change.
Next upb8 building & changing data; b9 investigates the March revenue dip you just found.