S039 · Window Functions — the Game-Changer
Keep every row AND attach a group-wide computation. Learn PARTITION BY as ‘per-bucket without collapsing’, the RANK vs DENSE_RANK vs ROW_NUMBER tie-breaker table, the frame clause (ROWS vs RANGE) that silently changes running totals, LAG/LEAD for period-over-period, and why every senior SQL user reaches for a window function before a correlated subquery.
🎯 Use OVER(PARTITION BY … ORDER BY …) to solve top-N-per-group, running totals, moving averages, and period-over-period comparisons — the queries that separate SQL users from SQL engineers.
Why this session exists
Every dashboard eventually asks something a plain GROUP BY cannot express. "Show every employee and their rank within their department." "Show every day's revenue and the trailing 7-day average." "Deduplicate at-least-once webhooks by keeping the earliest row per hash." Before window functions the workarounds were correlated subqueries (slow), self-joins (ugly), or temp tables (unreadable). Window functions replace 60 % of "fancy SQL" tricks with three keywords: OVER, PARTITION BY, and ORDER BY. This is the single feature that most upgrades a SQL user to a SQL engineer.
- Explain why a window function returns one row per input row (unlike GROUP BY which collapses).
- Pick ROW_NUMBER, RANK, or DENSE_RANK correctly based on how ties should behave.
- Write the top-N-per-group pattern with ROW_NUMBER + PARTITION BY, wrapped in a CTE.
- Use LAG / LEAD for day-over-day and month-over-month deltas without self-joins.
- Read a frame clause (ROWS BETWEEN … PRECEDING AND CURRENT ROW) and pick ROWS vs RANGE deliberately.
Prerequisites
- S037 — Joins — the self-join pattern you'll replace.
- S038 — Aggregations — GROUP BY / HAVING / correlated subqueries. Window functions are the sequel.
(a) Intuition · 5 min
A teacher hands back tests. Each student gets a slip with their own score, their rank in the class, and the class average printed at the bottom. Every student walks away with the same three numbers customised to them — nobody is collapsed into a single row of ‘class summary’.
A regular GROUP BY produces the ‘class summary’ view: one row per class with the average. A window function produces the ‘slip per student’ view: every student's row is preserved, but a class-wide computation is attached as an extra column.
AVG(salary) OVER (PARTITION BY dept) means: ‘for every row, compute AVG(salary) across all rows with the same dept, and attach it here — do not collapse’. Row count in equals row count out.
PARTITION BY chooses the bucket. ORDER BY (inside OVER) sequences rows within the bucket — needed for RANK, ROW_NUMBER, LAG, running totals. Miss the ORDER BY on a running total and you get a nondeterministic mess.
- ROW_NUMBER() — deterministic 1,2,3,… per partition. The deduplication workhorse.
- RANK() — Olympic-style: ties share a rank, next rank skips (1,2,2,4). Use for leaderboards.
- DENSE_RANK() — ties share a rank, next rank does NOT skip (1,2,2,3). Use for buckets.
- SUM/AVG/MIN/MAX OVER (…) — the aggregate returns one value per row instead of collapsing.
- LAG(x, n)/LEAD(x, n) — reach back or forward n rows within the partition. Kills self-joins.
- NTILE(k) — bucket rows into k equal-sized groups. Instant quartiles/deciles.
- FIRST_VALUE / LAST_VALUE — pluck the top/bottom row's value onto every row in the partition.
- 1996Red Brick + Oracle 8i ship early window syntaxData-warehouse engines add ranking and running totals as vendor extensions.
- 2003SQL:2003 standardises OVER()RANK, DENSE_RANK, ROW_NUMBER, and framed aggregates enter the standard.
- 2008PostgreSQL 8.4 adds window functionsThe moment the OSS community caught up. Every serious analytics query written after this uses them.
- 2018SQLite 3.25 adds window functionsPuts real analytics into every phone, browser, and embedded database. This tutorial's SQLite examples work today because of this release.
- 2020MySQL 8.0 + BigQuery ship QUALIFYThe QUALIFY clause finally lets you filter on window results without the CTE wrapper. Warehouse-dialect only for now.
(b) Visual walkthrough · 15 min
The employees table (reused from S036–S038)
| id | name | dept | salary | hired |
|---|---|---|---|---|
| 1 | Alice | Eng | 120000 | 2020-03-01 |
| 2 | Bob | Eng | 95000 | 2021-06-15 |
| 3 | Carol | HR | 80000 | 2019-01-10 |
| 4 | Dan | Eng | 140000 | 2018-09-20 |
| 5 | Eve | Sales | 110000 | 2022-02-01 |
| 6 | Frank | Sales | 60000 | 2023-11-05 |
GROUP BY collapses. OVER does not.
Three windows in one query
SELECT name, dept, salary,
RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rank_in_dept,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) AS rn_in_dept,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;Result — same 6 rows as input, three new columns:
| name | dept | salary | rank_in_dept | rn_in_dept | dept_avg |
|---|---|---|---|---|---|
| Dan | Eng | 140000 | 1 | 1 | 118333 |
| Alice | Eng | 120000 | 2 | 2 | 118333 |
| Bob | Eng | 95000 | 3 | 3 | 118333 |
| Carol | HR | 80000 | 1 | 1 | 80000 |
| Eve | Sales | 110000 | 1 | 1 | 85000 |
| Frank | Sales | 60000 | 2 | 2 | 85000 |
Notice dept_avg repeats for every row in the dept — that's the entire point.
The ranking family — tie-breaker cheat sheet
Given salaries [100, 90, 90, 80]:
1, 2, 3, 4 — deterministic, one per row
- Ties broken arbitrarily (add a secondary ORDER BY to control it)
- Use for deduplication: keep rn=1
- Use for pagination: WHERE rn BETWEEN 21 AND 30
- Never produces duplicates — the ‘unique identifier per partition’
1, 2, 2, 4 — Olympic style, gaps after ties
- Two silver medallists → next rank is 4, not 3
- Use for leaderboards where ‘tied for 2nd’ is meaningful
- Matches most humans' mental model of ranking
- Can produce duplicates within a partition
1, 2, 2, 3 — no gaps after ties
- Use for quantile grouping or salary bands
- ‘Top 3 distinct salaries’ = WHERE dense_rank \<= 3
- Matches ‘price tier’ or ‘performance band’ semantics
- Slightly slower than RANK on some engines
Running totals and moving averages — the frame clause
SELECT order_date, amount,
SUM(amount) OVER (ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
AVG(amount) OVER (ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
FROM orders;The ROWS BETWEEN … PRECEDING AND CURRENT ROW clause is the frame — the subset of the ordered partition to aggregate over.
PARTITION BY chooses the buckets (or one giant bucket if omitted).
ORDER BY (inside OVER) sequences rows within each bucket — mandatory for ranking, LAG, and framed aggregates.
ROWS or RANGE picks a subset of ordered rows relative to the current row.
SUM/AVG/MIN/MAX/COUNT is applied to the frame, one value per input row.
LAG and LEAD — reach across rows without a self-join
SELECT order_date, amount,
LAG(amount, 1) OVER (ORDER BY order_date) AS prev_amount,
LEAD(amount, 1) OVER (ORDER BY order_date) AS next_amount,
amount - LAG(amount, 1) OVER (ORDER BY order_date) AS day_over_day_change
FROM orders;Before window functions, "day-over-day change" needed a self-join on orders o1 JOIN orders o2 ON o2.order_date = o1.order_date - 1. Painful and slow. LAG(amount, 1) does the same thing in one clause and runs in a single sort.
The four killer patterns — commit to muscle memory
The window-function patterns you will use weekly for the rest of your career
"A window function is just a GROUP BY that keeps the other columns around."
GROUP BY collapses rows — n rows in, one row out per group. A window function preserves every row and attaches a computed value to each. They are different operations at different stages of the pipeline: grouping happens before SELECT, windowing happens as part of SELECT, over the already-grouped result.
Because the syntax rhymes — SUM(x) OVER (PARTITION BY k) looks like SUM(x) ... GROUP BY k — and for a simple total the numbers do match. The model breaks the moment you need both: you cannot filter on a window function in WHERE (windows have not been computed yet) and you cannot reference a window function inside an aggregate. That ordering constraint is not arbitrary; it is the pipeline position showing through.
Same aggregate, two shapes — and note where the filter is legal:
-- collapses: one row per customer
SELECT customer_id, SUM(amount) AS total
FROM orders GROUP BY customer_id;
-- preserves: every order row, plus that customer's total alongside
SELECT order_id, customer_id, amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total,
amount / SUM(amount) OVER (PARTITION BY customer_id) AS pct_of_customer
FROM orders;
-- ILLEGAL: window functions do not exist yet at WHERE time
-- SELECT ... WHERE ROW_NUMBER() OVER (...) = 1;
-- Correct: compute in a CTE, filter in the outer query
WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ts DESC) AS rn
FROM orders
)
SELECT * FROM ranked WHERE rn = 1;Why does adding ORDER BY inside an OVER() clause silently change SUM from a partition total into a running total? Nothing in the syntax says "running".
- 1A window function computes its result over a frame — a subset of the partition defined relative to the current row.forced by · "relative to the current row" is the entire distinction between a window function and an aggregate
- 2If there is no
ORDER BY, no row is before or after any other, so the only well-defined frame is the whole partition.forced by · a frame boundary like "preceding" is meaningless without an ordering to be preceding in - 3Adding
ORDER BYcreates that ordering, which makes relative frames expressible — and the SQL standard then applies a default frame rather than leaving it undefined.forced by · the language must pick a default, and the most common intent for an ordered window is cumulative - 4That default is
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW— everything from the start of the partition up to and including the current row.forced by · it is the frame that makes running totals work without extra syntax - 5Because it is
RANGEand notROWS, ties in the ordering column are included together: all peer rows with the same sort value share the same frame end.forced by · RANGE is defined on values of the ordering expression, whereas ROWS is defined on physical row positions
Therefore ORDER BY inside OVER() is not just sorting — it silently installs a cumulative frame, and the default frame is value-based, not row-based.
And note what this predicts: a running total over a date column with several rows per date will jump by the whole day's worth at once for every row of that day, because they are peers under RANGE. Switch to ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW and each row increments individually. Go run both on data with duplicate sort keys — the difference surprises almost everyone, and it is a genuine source of wrong dashboards.
Sort the rows into partitions. Now walk down them one at a time with a spotlight. For each row, the spotlight illuminates a frame — some neighbourhood of rows — and the window function reports one number computed over exactly what is lit. Then the spotlight moves on, and the row keeps its number.
Every window function is a choice of three things and nothing else: PARTITION BY (which rows are in the same world), ORDER BY (the direction the spotlight travels), and the frame (how wide the beam is).
- Window functions run after
WHERE,GROUP BYandHAVING, and beforeORDER BY. To filter on one, wrap it in a CTE. ROW_NUMBERnever ties,RANKties and skips,DENSE_RANKties and does not skip. Deduplication wantsROW_NUMBER; leaderboards usually wantRANK.LAG/LEADare how you compute deltas, session gaps and state changes without a self-join — and they are dramatically cheaper than the self-join.- Prefer
ROWSoverRANGEunless you specifically want peer-group semantics. The default isRANGE, which is not usually what you meant.
Fire this model the moment you see: "latest record per key" · running totals or cumulative sums · period-over-period change · "top N within each group" · sessionisation from event gaps · percent-of-total on the same row as the detail · deduplication by a recency rule · any query where you were about to write a self-join on the same table.
You need the latest row per key from a very large event table. Window function, correlated subquery, or a join to a pre-aggregated max?
DISTINCT ON is Postgres, QUALIFY is Snowflake/Databricks — so it couples your SQL to one engineStart with ROW_NUMBER: it is correct by construction, handles ties explicitly through the tiebreak in ORDER BY, and reviewers can see exactly what "latest" means. The join-to-max form is the classic optimisation and also the classic duplicate-row bug — if you use it, you must be able to prove the max is unique per key.
At scale the real win is upstream of all three: partition or cluster the table by the key so the window sort happens within a partition instead of across the whole dataset. The query text matters much less than whether the engine has to shuffle the entire table to establish the ordering.
(c) Hands-on · 25 min
Save as windows_lab.py, run python3 windows_lab.py. SQLite 3.25+ has window functions built in.
"""windows_lab.py — every window-function pattern you'll ever need."""
from __future__ import annotations
import sqlite3
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.executescript("""
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
dept TEXT NOT NULL,
salary INTEGER,
hired TEXT
);
INSERT INTO employees VALUES
(1,'Alice','Eng', 120000,'2020-03-01'),
(2,'Bob', 'Eng', 95000,'2021-06-15'),
(3,'Carol','HR', 80000,'2019-01-10'),
(4,'Dan', 'Eng', 140000,'2018-09-20'),
(5,'Eve', 'Sales', 110000,'2022-02-01'),
(6,'Frank','Sales', 60000,'2023-11-05'),
(7,'Heidi','Eng', 95000,'2024-01-01'); -- ties with Bob
CREATE TABLE sales (
day TEXT NOT NULL,
region TEXT NOT NULL,
amount INTEGER NOT NULL
);
INSERT INTO sales VALUES
('2026-01-01','South',100),('2026-01-01','North',200),
('2026-01-02','South',150),('2026-01-02','North',180),
('2026-01-03','South',300),('2026-01-03','North',210),
('2026-01-04','South',120),('2026-01-04','North',260),
('2026-01-05','South',400),('2026-01-05','North',300),
('2026-01-06','South',350),('2026-01-06','North',270),
('2026-01-07','South',450),('2026-01-07','North',330);
CREATE TABLE events (
user_id INTEGER NOT NULL,
event_time INTEGER NOT NULL -- unix seconds
);
INSERT INTO events VALUES
(1, 1000), (1, 1300), (1, 1500), -- one session
(1, 5000), (1, 5100), -- new session (gap > 30min)
(2, 2000), (2, 2200), (2, 8000); -- two sessions
""")
def run(label: str, sql: str) -> None:
print(f"\n-- {label} --")
for row in cur.execute(sql):
print(" ", row)
# ---- 1. Three windows in one query ----
run("1. Rank, row_number, dept_avg — three windows, one pass", """
SELECT name, dept, salary,
RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rk,
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC, id) AS rn,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees
ORDER BY dept, rk, id;
""")
# ---- 2. Top-2 per group — the killer pattern ----
run("2. Top 2 earners per dept (wrap in subquery, filter on rn)", """
SELECT name, dept, salary FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC, id) AS rn
FROM employees
) t
WHERE rn <= 2
ORDER BY dept, salary DESC;
""")
# ---- 3. RANK vs DENSE_RANK on ties (Bob + Heidi tied at 95000) ----
run("3. RANK vs DENSE_RANK on tied salaries", """
SELECT name, salary,
RANK() OVER (ORDER BY salary DESC) AS rk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS drk
FROM employees
ORDER BY salary DESC, name;
""")
# ---- 4. Running total + 7-day moving average per region ----
run("4. Running total & 3-day moving average per region", """
SELECT day, region, amount,
SUM(amount) OVER (PARTITION BY region ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running,
AVG(amount) OVER (PARTITION BY region ORDER BY day
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS ma_3d
FROM sales
ORDER BY region, day;
""")
# ---- 5. LAG — day-over-day change per region ----
run("5. LAG — day-over-day delta", """
SELECT day, region, amount,
LAG(amount) OVER (PARTITION BY region ORDER BY day) AS prev_amt,
amount - LAG(amount) OVER (PARTITION BY region ORDER BY day) AS delta
FROM sales
ORDER BY region, day;
""")
# ---- 6. NTILE — quartiles by salary ----
run("6. NTILE(4) — instant quartile buckets", """
SELECT name, salary, NTILE(4) OVER (ORDER BY salary DESC) AS quartile
FROM employees
ORDER BY salary DESC;
""")
# ---- 7. FIRST_VALUE — attach the dept's top earner to every row ----
run("7. FIRST_VALUE — top earner column beside every row", """
SELECT name, dept, salary,
FIRST_VALUE(name) OVER (PARTITION BY dept ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS top_earner
FROM employees
ORDER BY dept, salary DESC;
""")
# ---- 8. Sessionisation — 30-min gap rule ----
run("8. Sessionise events by 30-minute gap (1800 sec)", """
WITH gaps AS (
SELECT user_id, event_time,
event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS gap
FROM events
),
flagged AS (
SELECT user_id, event_time,
CASE WHEN gap IS NULL OR gap > 1800 THEN 1 ELSE 0 END AS is_new_session
FROM gaps
)
SELECT user_id, event_time,
SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_id
FROM flagged
ORDER BY user_id, event_time;
""")
# ---- 9. Deduplication — keep the earliest hire per (dept, salary) ----
run("9. Deduplicate: keep earliest row per (dept, salary)", """
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY dept, salary ORDER BY hired) AS rn
FROM employees
) WHERE rn = 1
ORDER BY dept, salary DESC;
""")
# ---- 10. TRAP: filtering on window column in same SELECT ----
try:
cur.execute("""
SELECT name, RANK() OVER (ORDER BY salary DESC) AS rk
FROM employees WHERE rk <= 3;
""").fetchall()
except sqlite3.OperationalError as e:
print("\n-- 10. TRAP: filter on window column in same SELECT --")
print(" Expected error:", e)
conn.close()Anatomy of the script
Extend the query to compute each employee's percentile within their department, then find the bottom half of each department:
WITH ranked AS (
SELECT name, dept, salary,
PERCENT_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS pct
FROM employees
)
SELECT name, dept, salary, ROUND(pct, 2) AS pct_from_top
FROM ranked
WHERE pct >= 0.5
ORDER BY dept, pct;Now change the frame in Query 4 from ROWS BETWEEN 2 PRECEDING AND CURRENT ROW to RANGE BETWEEN INTERVAL '2 days' PRECEDING AND CURRENT ROW on Postgres (SQLite doesn't support RANGE with intervals). See how it behaves differently when a day has no sales — RANGE gaps stay in the calendar, ROWS just counts adjacent rows.
(d) Production reality · 15 min
A payments team wanted to drop duplicate transaction webhooks. Someone wrote DELETE FROM tx WHERE hash IN (SELECT hash FROM tx GROUP BY hash HAVING COUNT(*) > 1). That deletes every row that shares a duplicated hash — including the original. Every duplicated transaction is now gone entirely from the table.
Use ROW_NUMBER to mark the keeper: DELETE FROM tx WHERE id IN (SELECT id FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY hash ORDER BY created_at) AS rn FROM tx) t WHERE rn > 1). This keeps the earliest row per hash and deletes only later duplicates. Ship this pattern in every ETL codebase — it's the same 4 lines every time.
Early Google Analytics used a MapReduce job that self-joined every event against every other event to find the previous timestamp per user. On a 1-billion-event day this was O(n²) — hours of runtime, huge shuffle. Every ‘compute session id’ story from the early data-warehouse era looks like this.
The modern pattern is exactly Query 8: LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) gives the gap, a CASE flags gaps > 30 min as new sessions, and SUM(flag) OVER (…) as a running total assigns a session id. Linear pass, one sort per user. Runs in minutes on the same billion-event day.
A finance team wrote SUM(amount) OVER (ORDER BY order_date) for a daily running-total report. Their orders table had multiple orders per day. The default frame `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` lumps all rows sharing the same `order_date` into one frame — so every order on day D sees the same running total (the total through end-of-day D), not the total through their own row. Downstream dashboards that plotted per-order cumulatives showed flat plateaus and then jumps between days instead of smooth growth.
Write the frame explicitly: SUM(amount) OVER (ORDER BY order_date, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). ROWS advances one row at a time regardless of ties; the tie-breaker in ORDER BY makes the sequence deterministic. Habit: never omit the frame clause on a running total.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- Why does a window function keep every row but GROUP BY collapses? (partition without projection)
- When would you use ROW_NUMBER vs RANK? (deduplication vs leaderboards)
- Why can't you filter on a window column in the same WHERE? (WHERE runs first — need a CTE)
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.