Module 4 · Advanced SQL
Window functions in depth
Control exactly which rows a window function sees with frames, and use moving averages, shares of a total, LAG, LEAD and FIRST_VALUE without the common traps.
About 25 minutes
The problem
Harbourline's monthly bookings bounce around: 146 in April 2025, 106 in June, 142 in July. The operations director asks, "Is volume actually going up or down, or is it just noise?" A month-by-month chart is too jumpy to answer that. What she needs is a moving average, which smooths each month with the months before it.
In SQL for Data Analysis you met RANK, ROW_NUMBER, running totals and LAG. This lesson covers the part that trips up experienced analysts: which rows a window function actually looks at.
The concept
The frame
Inside OVER (…), PARTITION BY picks the group and ORDER BY sorts it. The frame then picks which rows of the group the function uses for the current row:
SQL
AVG(shipments) OVER (
ORDER BY month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- this month and the two before
)| Frame | Rows used |
|---|---|
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | this row and the two before: a 3-period moving window |
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | everything up to this row: a running total |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | the whole partition |
| (no ORDER BY) | the whole partition |
Two default-frame traps
- With
ORDER BYand no frame, the default isRANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE treats rows with the sameORDER BYvalue as one step. A running total ordered bybooking_dategives every shipment booked on the same day the same total. If you want one step per row, writeROWSand add a tie-breaker (ORDER BY booking_date, shipment_id). LAST_VALUE(x) OVER (ORDER BY …)returns the current row's value, because the default frame stops at the current row. Give it the whole partition, or useFIRST_VALUEwith the order reversed.
Shares of a total
SUM(x) OVER () is the grand total on every row, and SUM(x) OVER (PARTITION BY mode) is the mode's total. Divide by either to get a share without a second query.
LAG and LEAD
LAG(x, n, default) looks n rows back (1 by default) and LEAD looks forward. They're how you calculate month-on-month change, or the gap between one order and the next.
Example
A 3-month moving average of bookings, showing it only once three months are available:
WITH monthly AS (
SELECT strftime('%Y-%m', booking_date) AS month, COUNT(*) AS shipments
FROM shipments
GROUP BY month
)
SELECT
month,
shipments,
CASE
WHEN COUNT(*) OVER w = 3 THEN ROUND(AVG(shipments) OVER w, 1)
END AS moving_avg_3,
shipments - LAG(shipments) OVER (ORDER BY month) AS change_vs_last_month
FROM monthly
WINDOW w AS (ORDER BY month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
ORDER BY month;The moving average sits between about 126 and 140 all the way through. June 2025's 106 is a dip, not a trend, and volume is broadly flat. That's the answer to the director's question, and it's far more reliable than reading the raw months.
Walkthrough
- Run the example.
WINDOW w AS (…)names a window once so several functions can share it. - Remove the
CASEso the first two months show a "moving average" of one and two months. That's why it's worth hiding incomplete windows: January's "average" is just January. - Run the RANGE trap for yourself. Look at the rows where several shipments share a booking date:
SELECT
shipment_id,
booking_date,
freight_charge,
SUM(freight_charge) OVER (ORDER BY booking_date) AS range_total,
SUM(freight_charge) OVER (ORDER BY booking_date, shipment_id ROWS UNBOUNDED PRECEDING) AS rows_total
FROM shipments
ORDER BY booking_date, shipment_id
LIMIT 12;- Notice that
range_totaljumps once per day, whilerows_totalclimbs once per shipment. Both are "correct", but they answer different questions.
Practice
Practice
For delivered shipments, show each route's share of its mode's freight charges. Show mode, route_id, charges and pct_of_mode (1 decimal place), ordered by mode, then charges descending.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Practice
For customer 8 (Harmattan Agro), list each shipment with the days since that customer's previous booking. Show shipment_id, booking_date and days_since_previous (a whole number; NULL for the first), ordered by booking_date then shipment_id.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Challenge
Challenge · optional
For customers with at least 20 shipments, find the average gap in days between consecutive bookings. Show customer_id, shipments and avg_gap_days (1 decimal place), shortest gap first.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
More practice
Drill · optional
Show payments received per month in 2026 with the percentage change from the previous month. Show month, received and pct_change (1 decimal place, NULL for January), ordered by month.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Drill · optional
For each customer who has booked, show customer_id and first_route: the route_id of their earliest booking (ties broken by the lower shipment_id). Use FIRST_VALUE. One row per customer, ordered by customer_id.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Drill · optional
Show each mode's share of all delivered freight charges: mode, charges and pct_of_total (1 decimal place), largest first.
Ctrl + Enter to run. Tab indents; press Esc then Tab to leave the editor.
Check your understanding
Answer every question to check.