2.3 Window functions¶
Concept¶
GROUP BY collapses rows. Window functions do something more powerful: they compute
across a set of related rows while keeping every original row. That lets you answer
questions like "rank each product within its category" or "what's the running total of
revenue up to this day" — where you need both the detail row and an aggregate beside it.
Every window function has an OVER (…) clause with three optional parts:
PARTITION BY— split rows into groups the function resets on (a miniGROUP BYthat doesn't collapse). E.g. rank products within each category.ORDER BY— the order the function walks rows in (needed for ranking, running totals,LAG/LEAD).- frame (e.g.
ROWS BETWEEN …) — which rows around the current one to include.
Common functions: ROW_NUMBER() (unique 1,2,3…), RANK() (ties share a rank, gaps
after), SUM() OVER (running / partitioned totals), and LAG()/LEAD() (reach to
the previous/next row — perfect for day-over-day change).
flowchart TB
A[All rows kept] --> B{OVER}
B --> P[PARTITION BY category]
B --> O[ORDER BY revenue DESC]
P --> F["ROW_NUMBER / RANK / SUM / LAG"]
O --> F
F --> R[one value per row]
How a window function actually works¶
Hold onto one contrast before we start. In 2.2, GROUP BY category
took many rows and collapsed them into one summary row per category — you got the total, but
you lost the individual products. A window function does the opposite: it computes the same kind
of aggregate, but hands the answer back on every original row. Ten products in a category go in,
ten rows come out — each now carrying its category's rank, running total, or share alongside it.
The magic word is OVER (…). Any aggregate you already know (SUM, AVG, COUNT) becomes a
window function the moment you write OVER after it instead of GROUP BY beside it. Inside the
parentheses you describe the window — the set of related rows this row's answer is computed from —
using up to three parts:
PARTITION BY col— split rows into groups the function resets on. Think of it as "aGROUP BYthat doesn't collapse."PARTITION BY categorymeans "rank/total within each category, starting fresh at each new category." Leave it out and the whole result is one big partition.ORDER BY col— the walk order: the sequence the function steps through rows in. It's what makes ranking meaningful (rank by revenue) and running totals possible (accumulate in date order).LAG/LEADneed it too — "previous row" only means something once rows have an order.- frame (e.g.
ROWS BETWEEN … PRECEDING AND CURRENT ROW) — which rows around the current one to fold in.ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWmeans "everything from the start up to and including me" (a running total).ROWS BETWEEN 6 PRECEDING AND CURRENT ROWmeans "me plus the 6 rows before me" (a 7-row moving window). Omit the frame and, with anORDER BY, most engines default to "start of partition through the current row."
The one-line mental model
GROUP BY = collapse rows into groups. OVER (…) = keep every row, but let each one
see its group. Same aggregates, opposite effect on row count.
Lab¶
Work against the ShopFlow source. We build a small base with a CTE (WITH … — covered
in 2.4), then apply windows.
Run these in SQLPad on the Lakehouse (Trino) — <your username> connection (not ShopFlow — OLTP) or Superset SQL Lab (see 2.1). In Superset, pick the shopflow / public schema and skip the
USEline below.
USE sets the default catalog + schema so you can write orders instead of the full
shopflow.public.orders every time.
What's a CTE?
Every query below opens with WITH name AS ( … ) — a Common Table Expression (CTE). It's
a named, throwaway result you build first, then query from as if it were a table. Here we use it to
do the ordinary join-and-aggregate work (compute revenue per product, per day, per customer)
first, and then apply the window function to that clean base. CTEs get their own lesson in
2.4 — for now just read WITH product_rev AS (…) as "let product_rev mean this
sub-result."
1 · Rank products within each category¶
Business question: within each category, which products earn the most — and what are the top 3? This is the headline window pattern: rank the detail rows without collapsing them.
Rank products within each category by revenue — keep only each category's top 3:
WITH product_rev AS (
SELECT p.category,
p.name AS product,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products AS p ON p.product_id = oi.product_id
WHERE o.status = 'delivered'
GROUP BY p.category, p.name
)
SELECT category, product, revenue, rnk
FROM (
SELECT category, product, revenue,
RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rnk
FROM product_rev
) AS ranked
WHERE rnk <= 3
ORDER BY category, rnk;
Read it clause by clause:
WITH product_rev AS (…)— the CTE first builds the plain base: joinorders × order_items × products, keep onlydeliveredrows, andGROUP BY p.category, p.nameto get one revenue number per product. This is exactly the aggregation from 2.2 — nothing new yet.RANK() OVER (PARTITION BY category ORDER BY revenue DESC) AS rnk— the window function.RANK()hands out positions 1, 2, 3, … by the order you give it.PARTITION BY categoryresets the ranking at every new category — so each category has its own#1. (Drop it and you'd get a single global ranking across all products.)ORDER BY revenue DESCis the walk order: highest revenue gets rank 1. Note thisORDER BYlives inside theOVER (…)and only steers the ranking — it is not the query's final sort.- The nested subquery — you can't filter on a window result in the same
SELECTthat computes it (window functions are evaluated afterWHERE). So we computernkin an inner query, then the outerWHERE rnk <= 3keeps only each category's top three. TheAS rankedalias names that inner result — Trino doesn't require it, but Postgres does, so it keeps the query portable. ORDER BY category, rnk— the final sort of the output: group the rows by category, and within each category show rank 1, then 2, then 3.
Conceptually the result is a tidy leaderboard — every category block looks like:
| category | product | revenue | rnk |
|---|---|---|---|
| Books | Product 137 | 9 120 | 1 |
| Books | Product 42 | 7 400 | 2 |
| Books | Product 88 | 6 010 | 3 |
| Electronics | … | … | 1 |
Why RANK() here and not ROW_NUMBER()?
If two products in a category tie on revenue, RANK() gives them the same position (say two
#2s) and then skips to #4 — so rnk <= 3 can legitimately return four rows. ROW_NUMBER()
would force an arbitrary 1-2-3 with no ties, quietly dropping one of the tied products. Pick the tie
behaviour you actually want.
2 · Running daily revenue¶
Business question: how does cumulative revenue build up over time — the total "so far" on each day?
That's a running total: SUM walked forward one day at a time, kept on every row.
Running daily revenue — a cumulative total that grows day by day:
WITH daily AS (
SELECT CAST(o.order_ts AS date) AS sales_date,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'delivered'
GROUP BY CAST(o.order_ts AS date)
)
SELECT sales_date,
revenue,
SUM(revenue) OVER (ORDER BY sales_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
AS running_total
FROM daily
ORDER BY sales_date
LIMIT 30;
Read it clause by clause:
WITH daily AS (…)— the CTE rolls the raw line items up to one row per calendar day.CAST(o.order_ts AS date)chops the timestamp down to just the date (so all of a day's orders land in the same bucket), andSUM(quantity * unit_price)totals that day's revenue.revenue— the plain per-day total, shown as-is (the original row is kept).SUM(revenue) OVER (ORDER BY sales_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)— the running total.SUM(revenue) OVER (…)— sameSUMyou know, butOVERmakes it a window aggregate: it adds up a range of rows and reports the answer on each row instead of collapsing them.ORDER BY sales_date— walk the days in date order (earliest first). Order matters: "so far" only means something once time flows one way.ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW— the frame.UNBOUNDED PRECEDING= the very first day;CURRENT ROW= today. So on any given day the sum covers every day from the start up to and including this one — the definition of a running total.ORDER BY sales_date/LIMIT 30(the outer ones) — sort the output chronologically and show the first 30 days.
Conceptually revenue is the daily bar and running_total is the ever-climbing line above it:
| sales_date | revenue | running_total |
|---|---|---|
| 2024-01-01 | 1 200 | 1 200 |
| 2024-01-02 | 800 | 2 000 |
| 2024-01-03 | 1 500 | 3 500 |
3 · Day-over-day change with LAG¶
Business question: did revenue go up or down versus yesterday, and by what percent? To compare a
row to the row before it, you need to reach back one row — that's LAG.
Day-over-day change with LAG — compare each day to the day before:
WITH daily AS (
SELECT CAST(o.order_ts AS date) AS sales_date,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'delivered'
GROUP BY CAST(o.order_ts AS date)
)
SELECT sales_date,
revenue,
LAG(revenue) OVER (ORDER BY sales_date) AS prev_day,
revenue - LAG(revenue) OVER (ORDER BY sales_date) AS delta,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY sales_date))
/ NULLIF(LAG(revenue) OVER (ORDER BY sales_date), 0), 1) AS pct_change
FROM daily
ORDER BY sales_date
LIMIT 30;
Read it clause by clause:
WITH daily AS (…)— the same per-day base as before (one revenue total per date).LAG(revenue) OVER (ORDER BY sales_date) AS prev_day—LAGreaches backward one row and returns that row'srevenue. WithORDER BY sales_date, "one row back" = yesterday's revenue, placed beside today's. (On the very first day there is no previous row, soprev_dayisNULL.)revenue - LAG(revenue) OVER (…) AS delta— today minus yesterday: the raw up/down amount.- The
pct_changeexpression — the same delta as a percentage of yesterday: 100.0 * (revenue - prev) / prev— percent change; the100.0(a decimal) forces decimal division so you don't lose the fraction to integer math.NULLIF(prev, 0)— a safety valve: if yesterday's revenue was0,NULLIFturns the divisor intoNULLso the database returnsNULLinstead of erroring on divide-by-zero.ROUND(…, 1)— round the percentage to one decimal place.ORDER BY sales_date/LIMIT 30— chronological output, first 30 days.
The payoff is a trend row you can read at a glance:
| sales_date | revenue | prev_day | delta | pct_change |
|---|---|---|---|---|
| 2024-01-01 | 1 200 | NULL | NULL | NULL |
| 2024-01-02 | 800 | 1 200 | −400 | −33.3 |
| 2024-01-03 | 1 500 | 800 | 700 | 87.5 |
Understanding LAG in full¶
LAG lets you read a neighbouring row without a self-join — its whole reason to exist. The
complete shape is:
expression— the column (or expression) to fetch from the earlier row, e.g.revenue.offset— how many rows back to reach. Defaults to1(the row immediately before);LAG(revenue, 7)reaches back 7 rows — handy for week-over-week comparisons.default— what to return when no such row exists (instead ofNULL).LAG(revenue, 1, 0)gives0on the first row rather thanNULL— useful so downstream maths doesn't break on the edge.ORDER BYdefines what "previous" even means —LAGwalks the rows in that order, so here "one row back" is literally "yesterday".PARTITION BYmakes "previous" reset inside each group. Without it,LAGreaches across the whole result; withPARTITION BY customer_id ORDER BY order_tsit returns each customer's own previous order — it never bleeds from one customer into the next, and the first order of every customer comes backNULL.
LAG looks back, LEAD looks forward
LAG(col) = the previous row's value; LEAD(col) = the next row's value — same
mechanics, opposite direction. Reach for them whenever you compare a row to its neighbour:
day-over-day or week-over-week change, the gap between consecutive events, or "did this
customer's status change since last time?"
4 · 7-day moving average¶
Business question: what's the smoothed revenue trend, ignoring day-to-day noise? A moving average averages each day together with a fixed window of recent days — done with a rows frame.
7-day moving average using a rows frame:
WITH daily AS (
SELECT CAST(o.order_ts AS date) AS sales_date,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.status = 'delivered'
GROUP BY CAST(o.order_ts AS date)
)
SELECT sales_date, revenue,
AVG(revenue) OVER (ORDER BY sales_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7d
FROM daily
ORDER BY sales_date
LIMIT 30;
Read it clause by clause:
WITH daily AS (…)— the familiar per-day revenue base.AVG(revenue) OVER (ORDER BY sales_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7d— the moving average.AVG(revenue) OVER (…)— average revenue across the framed rows, reported on every day.ORDER BY sales_date— walk in date order so "the 6 days before" is meaningful.ROWS BETWEEN 6 PRECEDING AND CURRENT ROW— the frame is today plus the 6 days before it = a 7-row sliding window. Contrast this with the running total'sUNBOUNDED PRECEDING: there the window grew forever; here it stays a fixed width of 7 and slides forward one day at a time.- The first few days have fewer than 6 days behind them, so
ma_7daverages whatever rows exist so far — the window is simply shorter at the very start.
Running total vs moving average — it's all in the frame
Same OVER (ORDER BY …), one word different in the frame:
UNBOUNDED PRECEDING → CURRENT ROW = cumulative (grows without bound);
6 PRECEDING → CURRENT ROW = sliding (fixed 7-row width). The frame is the dial that turns one
into the other.
ROW_NUMBER vs RANK vs DENSE_RANK
Use ROW_NUMBER() when you need exactly one row per group (e.g. the single top
product). Use RANK() when ties should share a position (leaving gaps after).
DENSE_RANK() is like RANK() but without the gaps.
The rest of the window family — same OVER (…) mechanics, different question:
LEAD(col)— the next row's value (the mirror ofLAG): "what did the customer buy after this order?"FIRST_VALUE(col)/LAST_VALUE(col)— the first/last value in the window: "revenue on this customer's very first active day."NTILE(n)— split rows intonequal buckets: "which quartile of spend is this customer in?"
Business question: split customers into four equal spend tiers — who are the top 25%? That's what
NTILE(4) does: it slices the ordered rows into 4 same-sized groups and labels each row 1–4.
-- Rank customers into 4 spend quartiles (1 = top spenders)
WITH cust_rev AS (
SELECT o.customer_id, SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'delivered'
GROUP BY o.customer_id
)
SELECT customer_id, revenue,
NTILE(4) OVER (ORDER BY revenue DESC) AS spend_quartile
FROM cust_rev
ORDER BY revenue DESC
LIMIT 20;
Read it clause by clause:
WITH cust_rev AS (…)— total revenue per customer (one row each), delivered orders only.NTILE(4) OVER (ORDER BY revenue DESC) AS spend_quartile— order customers by revenue (biggest first), then divide them into 4 equal-sized buckets. Bucket 1 is the top-spending quarter, bucket 4 the bottom quarter. NoPARTITION BYhere, so the quartiles span all customers at once.ORDER BY revenue DESC/LIMIT 20— show the biggest spenders first; they'll all carryspend_quartile = 1. Segments like these feed straight into marketing and Gold-layer dashboards.
Challenge¶
For each customer, find their first delivered order (earliest order_ts) and show
customer name, order id, and order timestamp — exactly one row per customer.
Which window pattern do you need?
"Exactly one row per customer" is the giveaway for ROW_NUMBER(): PARTITION BY customer_id
(restart per customer) ORDER BY order_ts ASC (earliest order first) numbers each customer's orders
1, 2, 3…. Compute that in a CTE, then keep only seq = 1 — the earliest. ROW_NUMBER (not
RANK) guarantees a single winner even if two orders share the same timestamp.
Solution
WITH ranked AS (
SELECT c.full_name,
o.order_id,
o.order_ts,
ROW_NUMBER() OVER (PARTITION BY o.customer_id
ORDER BY o.order_ts ASC) AS seq
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.status = 'delivered'
)
SELECT full_name, order_id, order_ts
FROM ranked
WHERE seq = 1
ORDER BY order_ts
LIMIT 20;
🎯 This runs unchanged on Azure, Databricks, Snowflake & Fabric
What you just did: used window functions (OVER / PARTITION BY, ROW_NUMBER/
RANK, running SUM, LAG, moving averages) to rank and trend without collapsing rows.
- Azure Databricks / Snowflake —
OVER / PARTITION BY / ROW_NUMBER / RANK / SUM OVER / LAG / LEAD, includingROWS BETWEEN …frames, are standard ANSI SQL and run unchanged. - Microsoft Fabric — identical window functions in the SQL endpoint over OneLake Delta.
- Azure Data Factory — build them no-code with the Window transformation in a Mapping Data Flow (set partition, sort, and frame).
Window functions are one of the most valued (and 100% portable) SQL skills — the code here runs unchanged on every cloud warehouse.
Key terms, at a glance¶
| Term | Plain meaning |
|---|---|
| Window function | Computes across related rows without collapsing them (contrast GROUP BY) |
OVER (…) |
Turns an aggregate into a window function; defines the window (partition, order, frame) |
PARTITION BY |
A "GROUP BY that doesn't collapse" — resets the calculation per group |
ORDER BY (inside OVER) |
The walk order: makes ranking, running totals, and LAG/LEAD meaningful |
Frame (ROWS BETWEEN) |
Which nearby rows to include around the current row |
UNBOUNDED PRECEDING … CURRENT ROW |
Frame for a cumulative running total (start → now) |
n PRECEDING … CURRENT ROW |
Fixed-width sliding frame for a moving average (e.g. 6 = 7-row window) |
ROW_NUMBER() |
Unique 1,2,3… — one winner per group, no ties |
RANK() |
Ties share a rank, then a gap follows (1,1,3…) |
DENSE_RANK() |
Like RANK but no gap after ties (1,1,2…) |
SUM() OVER (…) |
Running / partitioned total kept on every row |
AVG() OVER (…) |
Windowed average — e.g. a moving average over a rows frame |
LAG(col) / LEAD(col) |
Read the previous / next row's value |
NTILE(n) |
Split ordered rows into n equal buckets (e.g. quartiles) |
NULLIF(x, 0) |
Return NULL instead of dividing by zero |
| Running total | Cumulative sum via SUM() OVER (ORDER BY … ROWS UNBOUNDED PRECEDING …) |
CTE (WITH … AS) |
A named, throwaway sub-result you query from (see 2.4) |
You can now…¶
- Explain
OVER,PARTITION BY,ORDER BY, and row frames - Rank within groups (
ROW_NUMBER/RANK) and build running totals & moving averages - Compare rows over time with
LAG/LEADfor day-over-day metrics