Window Functions: Intermediate
SELECTstream_date,streams,LAG(streams, 1) OVER (ORDER BY stream_date) AS prev_day,streams - LAG(streams, 1) OVER (ORDER BY stream_date) AS day_changeFROM daily_streams
LEAD looks the other way
LEAD has the same shape and reaches forward. LEAD(streams) on 2024-03-01 returns 1500, the next day's number, and the last row gets NULL because nothing follows it. A row that knows tomorrow's value can answer which days were followed by a drop, and the CASE below labels each one.
Offset and default
On the 5-row daily_streams table, what does LEAD(streams, 2) OVER (ORDER BY stream_date) return for the 2024-03-03 row?
Previous row within each artist
| artist | stream_date | streams | prev_day |
|---|---|---|---|
| Kite | 2024-03-01 | 300 | 0 |
| Kite | 2024-03-02 | 280 | 300 |
| Kite | 2024-03-03 | 410 | 280 |
| Nova | 2024-03-01 | 500 | 0 |
| Nova | 2024-03-02 | 650 | 500 |
| Nova | 2024-03-03 | 620 | 650 |
SELECTartist,track,streams,FIRST_VALUE(streams) OVER (PARTITION BY artistORDER BY streams DESC) AS top_streamsFROM artist_tracks
LAST_VALUE sees less than you expect
NTH_VALUE picks a position
NTH_VALUE(track, 2) returns the value from the second row of the window. The same sizing rule applies: under an ordered window with no extra clause, the first row of a partition has not reached row 2 yet, so it gets NULL while every later row gets the runner-up. Stretching the window to the whole partition, as the Full window tab did, gives every row the same runner-up.
| artist | track | streams | runner_up |
|---|---|---|---|
| Kite | Wind | 2500 | Line |
| Kite | Line | 500 | Line |
| Nova | Orbit | 4000 | Dust |
| Nova | Dust | 3000 | Dust |
| Nova | Halo | 1000 | Dust |
On artist_tracks, what does FIRST_VALUE(track) OVER (PARTITION BY artist ORDER BY streams ASC) return for Nova's Orbit row?
| Boundary | What it points at | Typical use |
|---|---|---|
| UNBOUNDED PRECEDING | the first row of the partition | start of a running total |
| 2 PRECEDING | 2 rows before the current row | start of a trailing 3-row window |
| CURRENT ROW | the row being computed | end of a running total |
| 1 FOLLOWING | 1 row after the current row | end of a centred 3-row window |
| UNBOUNDED FOLLOWING | the last row of the partition | end of a whole-partition window |
SELECTsale_date,revenue,AVG(revenue) OVER (ORDER BY sale_dateROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS moving_avg_3FROM daily_revenue
ROWS counts rows, RANGE counts values
- The frame is the whole partition
- SUM returns the partition total on every row
- FIRST_VALUE and LAST_VALUE see every row
- Row order inside the partition carries no meaning
- The frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
- SUM returns a running total
- LAST_VALUE sees up to the current row and its peers
- Tied rows share one frame and one result
- Write ROWS when the frame is a count of rows, such as the last 7 or the surrounding 3
- Pair a sliding average with a COUNT(*) over the same frame so partial edges are visible
- Spell the frame out whenever the function must see rows after the current one
- Rely on ROWS with a tied ORDER BY column; the split between ties changes between runs
- Assume an ordered window covers the whole partition; it stops at the current row
- Write n PRECEDING with RANGE when you mean rows; RANGE measures the sort value
On the 5-row daily_revenue table, what does SUM(revenue) OVER (ORDER BY sale_date ROWS BETWEEN 1 FOLLOWING AND 2 FOLLOWING) return for 2025-03-01?
SELECTcustomer_name,total_spent,NTILE(4) OVER (ORDER BY total_spent DESC) AS spending_quartileFROM customer_summary
When the rows do not divide evenly
| customer_name | total_spent | spending_tier |
|---|---|---|
| Dev | 2500 | 1 |
| Ann | 1800 | 1 |
| Finn | 1200 | 1 |
| Cara | 960 | 2 |
| Gia | 700 | 2 |
| Ben | 420 | 2 |
| Hal | 310 | 3 |
| Eve | 150 | 3 |
NTILE never gives 2 rows the same bucket on account of a tie. If Ben and Hal both had 420, one would still take the last slot of bucket 2 and the other would drop to bucket 3, in an order the engine picks. Add a tie-breaker to the ORDER BY, such as customer_name, so the split comes out the same on every run.
- Bucket sizes differ by at most 1 row
- The cutoffs move as the data changes
- A top bucket always exists, even when every customer is small
- Hard to explain as a figure: there's no fixed dollar line
- Bucket sizes vary with the data
- The cutoffs stay where the business set them
- A bucket can be empty when nobody crosses its line
- Easy to explain: spent over 1000 means tier 1
Bucketing inside partitions
A table has 10 rows. What bucket sizes does NTILE(4) OVER (ORDER BY score DESC) produce, from bucket 1 to bucket 4?
| track | stream_date | streams | vs_prev_day | share_of_day | day_rank |
|---|---|---|---|---|---|
| Orbit | 2024-03-01 | 800 | NULL | 80.0 | 1 |
| Orbit | 2024-03-02 | 1000 | 200 | 62.5 | 1 |
| Orbit | 2024-03-03 | 900 | -100 | 50.0 | 1 |
| Wind | 2024-03-01 | 200 | NULL | 20.0 | 2 |
| Wind | 2024-03-02 | 600 | 400 | 37.5 | 2 |
| Wind | 2024-03-03 | 900 | 300 | 50.0 | 1 |
Which OVER clause gives each track_days row its share of that day's total streams?
Filtering on a window result
- ▸Each distinct pair of PARTITION BY and ORDER BY needs its own sort of the rows
- ▸Windows that share a partition and order can be computed in the same sorted pass
- ▸A different frame on the same partition and order adds no sort
- ▸The 3-window query above sorts the rows by track and date, then sorts them again by date and streams
> Build a query over track_days that reports each track's change from its previous day and its rank within each day, then keeps only each day's leader. One tile tries to filter on the rank before the window exists and does not belong.
> Spotify's charts team ships a weekly report for a label: for each of the label's tracks and each day, streams against the previous day, a 3-day centred average, share of the label's streams that day, and which quartile of the label's catalogue the track sits in by total streams.
Compare each row to its neighbours, pull the leader out of a group, size the window exactly, and bucket rows into tiers.
Look back. Look forward. Frame it.
- Category
- SQL
- Difficulty
- intermediate
- Duration
- 39 minutes
- Challenges
- 21 hands-on challenges
Topics covered: LAG and LEAD, FIRST, LAST, NTH VALUE, Frame Specifications, NTILE and Bucketing, Multiple Windows
Lesson Sections
- LAG and LEAD (concepts: Row Offsets)
LAG reaches back a fixed number of rows inside a window and returns the value it finds there. The window's ORDER BY decides which row counts as previous: with ORDER BY stream_date, the previous row is the previous day. A row keeps every column it had, and LAG attaches one more column holding a neighbour's value, so a row can be compared to the row before it without a join. The engine sorts the 5 rows by stream_date and walks them in that order. 2024-03-02 looks back 1 row and finds 1200, so prev
- FIRST, LAST, NTH VALUE (concepts: First & Last Values)
FIRST_VALUE returns the value from the first row of the window, in the window's order. Where LAG reaches a fixed distance back, FIRST_VALUE reaches all the way to the start, so every row in a partition gets the same answer: the top of the list. Order the partition by streams descending and the first row is the artist's biggest track, which makes FIRST_VALUE the function for measuring every row against the leader. Orbit tops Nova's partition, so all 3 Nova rows, Dust and Halo included, read 4000
- Frame Specifications (concepts: Window Frames)
A frame is the slice of a partition that an aggregate window function reads for one row. The clause starts with ROWS or RANGE to pick the unit, then gives a start and an end. Each bound is either CURRENT ROW or a distance in one direction, n PRECEDING or n FOLLOWING, with UNBOUNDED in place of n to reach the edge of the partition. ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING covers 3 rows, the current row plus 1 on each side. The frame slides along with the current row, so each row's average is take
- NTILE and Bucketing (concepts: Bucketing)
NTILE(n) sorts the partition, cuts it into n groups of as equal a size as the row count allows, and labels each row with its group number from 1 to n. The numbering starts at the top of the order, so NTILE(4) OVER (ORDER BY total_spent DESC) puts the biggest spenders in bucket 1. The function answers a question RANK cannot: a rank says a customer is 6th, a quartile says they are in the bottom quarter, and that statement holds whether the table has 8 customers or 8 million. The 8 rows split into
- Multiple Windows (concepts: Multiple WINDOWs)
Each OVER clause is a window of its own, and a query can hold as many as it needs, partitioned and sorted however each column requires. The engine computes them all over the same input rows, so one SELECT can report how a track moved against yesterday, what share of the day it took, and where it ranked that day, all without a join or a second read of the table. The 3 columns below come from 3 windows that disagree on nearly everything. vs_prev_day partitions by track and orders by date, so Orbit