Search Tech Journey

Find topics, journeys and posts

6-month learning plan39 / 130
back to blog
data engineeringbeginner 55m read

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.

🗃️DatabasesM04 · Databases & SQL· Session 039 of 130 90 min

🎯 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.

You will be able to
  • 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



(a) Intuition · 5 min

A classroom's per-student report card with class averages printed alongside
🌍 Real world

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.

💻 Code world

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.

The window-function starter pack
  • 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.
  1. 1996
    Red Brick + Oracle 8i ship early window syntax
    Data-warehouse engines add ranking and running totals as vendor extensions.
  2. 2003
    SQL:2003 standardises OVER()
    RANK, DENSE_RANK, ROW_NUMBER, and framed aggregates enter the standard.
  3. 2008
    PostgreSQL 8.4 adds window functions
    The moment the OSS community caught up. Every serious analytics query written after this uses them.
  4. 2018
    SQLite 3.25 adds window functions
    Puts real analytics into every phone, browser, and embedded database. This tutorial's SQLite examples work today because of this release.
  5. 2020
    MySQL 8.0 + BigQuery ship QUALIFY
    The 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)

idnamedeptsalaryhired
1AliceEng1200002020-03-01
2BobEng950002021-06-15
3CarolHR800002019-01-10
4DanEng1400002018-09-20
5EveSales1100002022-02-01
6FrankSales600002023-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:

namedeptsalaryrank_in_deptrn_in_deptdept_avg
DanEng14000011118333
AliceEng12000022118333
BobEng9500033118333
CarolHR800001180000
EveSales1100001185000
FrankSales600002285000

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]:

ROW_NUMBER()

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’
RANK()

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
DENSE_RANK()

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.

1bucket
1. Partition

PARTITION BY chooses the buckets (or one giant bucket if omitted).

2sort
2. Order

ORDER BY (inside OVER) sequences rows within each bucket — mandatory for ranking, LAG, and framed aggregates.

3window
3. Frame

ROWS or RANGE picks a subset of ordered rows relative to the current row.

4compute
4. Aggregate

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

1. Top-N per group
ROW_NUMBER() OVER (PARTITION BY group ORDER BY score DESC), wrap in CTE, filter rn \<= N. Replaces every ‘top earner per dept’ correlated subquery.
top-N
2. Deduplication
ROW_NUMBER() OVER (PARTITION BY natural_key ORDER BY inserted_at) → keep rn=1, delete the rest. Standard at-least-once webhook cleanup.
dedupe
3. Running total / moving average
SUM/AVG OVER (ORDER BY t ROWS BETWEEN …). Cumulative revenue, 7-day active users, trailing p95.
cumulative
4. Period-over-period
LAG/LEAD OVER (PARTITION BY entity ORDER BY period). Day-over-day, week-over-week, cohort deltas.
delta
5. Sessionisation
LAG(event_time) OVER (PARTITION BY user ORDER BY event_time); flag new session when gap > 30 min; SUM(flag) as running session id. Powers every analytics platform.
session

Common misconception
✗ What most people think

"A window function is just a GROUP BY that keeps the other columns around."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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;
From first principles
Start with the question

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".

  1. 1
    A 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
  2. 2
    If 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
  3. 3
    Adding ORDER BY creates 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
  4. 4
    That 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
  5. 5
    Because it is RANGE and not ROWS, 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

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.

Mental modelA spotlight that travels down the rows

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 BY and HAVING, and before ORDER BY. To filter on one, wrap it in a CTE.
  • ROW_NUMBER never ties, RANK ties and skips, DENSE_RANK ties and does not skip. Deduplication wants ROW_NUMBER; leaderboards usually want RANK.
  • LAG/LEAD are how you compute deltas, session gaps and state changes without a self-join — and they are dramatically cheaper than the self-join.
  • Prefer ROWS over RANGE unless you specifically want peer-group semantics. The default is RANGE, which is not usually what you meant.
🔔 Fires when you see

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.

The tradeoff

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?

ROW_NUMBER() in a CTE, filter rn = 1
+ you gain one pass over the data, reads unambiguously, and generalises for free to top-N, to different tiebreak rules, and to carrying other columns along
− you pay requires a sort (or hash) per partition over the whole table, which for a large table is a full shuffle and a real memory cost; it processes every row even though you keep one per key
pick when you need more than the max — the whole row, or the top N, or a multi-column tiebreak
Join back to a GROUP BY max(ts)
+ you gain the aggregation side is algebraic and cheap, reduces to a small result early, and the optimiser can often push it down aggressively
− you pay two passes over the table plus a join; and it is subtly wrong when two rows share the max timestamp — you silently get duplicates back
pick when the max column is unique per key, and the table is large enough that reducing before joining is a real win
Engine-native shortcut (DISTINCT ON, ARRAY_AGG ordered, qualify)
+ you gain the engine often has a purpose-built operator that is meaningfully faster than a general window sort, and the intent is stated in one line
− you pay not portable — DISTINCT ON is Postgres, QUALIFY is Snowflake/Databricks — so it couples your SQL to one engine
pick when you are committed to that engine and the query is hot enough that the difference matters
What a senior engineer actually does

Start 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

Query 1 — three windows in one query
One table scan, three OVER clauses. All share the same PARTITION BY dept so the planner can compute them in one sort. Notice the ORDER BY tie-breaker `salary DESC, id` — without `id` the ROW_NUMBER assignment between Bob and Heidi would be nondeterministic across runs.
efficient
Query 2 — top-N wrap-in-subquery pattern
The universal top-N-per-group idiom in portable SQL. Snowflake / BigQuery users can drop the wrapper via `QUALIFY rn \<= 2`; everyone else needs the CTE or subquery.
pattern
Query 3 — ties diverge
Bob and Heidi tie at 95000. RANK gives 3, 3, 5 (gap after tie); DENSE_RANK gives 3, 3, 4 (no gap). Pick RANK for medals, DENSE_RANK for buckets.
tie
Query 4 — the frame in action
Running total uses `UNBOUNDED PRECEDING`; 3-day MA uses `2 PRECEDING`. Change `2` to `6` for a 7-day moving average — the exact query every product-analytics team runs.
frame
Query 5 — LAG replaces the self-join
The pre-window way was `orders o1 LEFT JOIN orders o2 ON o2.day = date(o1.day, '-1 day')`. LAG does the same in one clause, one sort.
replaces-join
Query 6 — NTILE for percentile buckets
Assigns each row to one of k equal-sized groups. Instant quartiles, deciles, or percentile bands with no math.
buckets
Query 7 — full-frame FIRST_VALUE
Note the explicit `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` — without it, FIRST_VALUE returns the current row's value (default frame is `RANGE … CURRENT ROW`). Same trap for LAST_VALUE.
trap
Query 8 — sessionisation in three CTEs
The exact algorithm Snowplow, Mixpanel, Amplitude use. LAG for the gap, CASE for the flag, SUM OVER for the running session id.
real-world
Query 9 — deduplication
The standard cleanup for at-least-once event streams. Replace `SELECT *` with `DELETE FROM tbl WHERE id IN (SELECT id FROM (…) WHERE rn > 1)` for the destructive form.
cleanup
Query 10 — the WHERE-vs-window trap
Fails loudly in SQLite. Some engines silently drop the filter. The fix is always: wrap in CTE/subquery, filter outside.
gotcha
Try itPercentile ranking within department

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.

💡 Hint · PERCENT_RANK returns 0 for the top row and approaches 1 for the bottom row within each partition.

(d) Production reality · 15 min

War story Any at-least-once webhook pipelineCommon dedup bug — deletes the good row along with the duplicates
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
For deduplication, always rank-then-filter, never GROUP BY HAVING. The whole point is to identify a specific keeper row, not to filter groups. If you find yourself writing HAVING for dedup, stop and reach for ROW_NUMBER.
War story Sessionisation in every analytics productSnowplow / Mixpanel / Amplitude / GA4 — all use LAG + SUM OVER
🔥 What broke

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 fix

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.

🎓 Lesson to steal
Anywhere you see ‘previous row per group’ or ‘time between consecutive events’, that's a LAG / LEAD pattern. Self-join versions are O(n²) and don't scale; window versions are O(n log n) and do.
Post-mortem
War story Common frame-clause bug — ROWS vs RANGE defaultSilently wrong running totals in any engine that defaults to RANGE
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
The default frame is RANGE, not ROWS. They coincide only when the ORDER BY column has no duplicates — which is almost never true on real data. Type the frame; save the debugging.

Where this shows up in the rest of the plan

Window functions are the backbone of analytics SQL
S040 · CTEs
The natural wrapper for filtering on window columns. Every ‘top-N per group’ becomes a WITH x AS (…) SELECT … WHERE rn = 1.
S041 · Indexes
Window functions do a sort per partition. An index that matches (PARTITION BY, ORDER BY) skips the sort — huge win at scale.
S049 · Query optimisation
Window functions show up as ‘WindowAgg’ nodes in EXPLAIN. Learn to read them.
S054 · Batch ETL
Deduplication with ROW_NUMBER is the standard cleanup step in dbt / Airflow pipelines.
S062 · Streaming analytics
Sessionisation via LAG maps directly to Flink / Spark Structured Streaming session windows.
S090 · Observability
p50 / p95 / p99 over rolling windows are PERCENTILE_CONT within a windowed frame.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

  1. Why does a window function keep every row but GROUP BY collapses? (partition without projection)
  2. When would you use ROW_NUMBER vs RANK? (deduplication vs leaderboards)
  3. 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.