Window Functions: Advanced
The same running total, 2 ways
Both queries return the same 5 rows. The work differs: the join shuffles both copies of the table on customer_id, materialises every pair, and aggregates; the window shuffles the table once on customer_id, sorts each partition by order_date, and adds as it streams. With thousands of orders per customer the join's intermediate rows run into the millions while the window's input stays the size of the table.
- Shuffles both sides on the join key
- Builds n(n + 1) / 2 pairs per key
- Needs a GROUP BY to fold the pairs back
- Intermediate size grows with n squared
- Shuffles the table once on the partition key
- Sorts n rows per key
- Emits one row per input row
- Intermediate size equals the input
Spotting a self-join that is a window in disguise
| Self-join pattern | What it computes | Window replacement |
|---|---|---|
| o2.order_date < o1.order_date, keep the latest o2 | the previous order | LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) |
| o2.order_date <= o1.order_date, SUM(o2.amount) | running total | SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) |
| join to a GROUP BY subquery on customer_id | share of customer total | amount * 1.0 / SUM(amount) OVER (PARTITION BY customer_id) |
| LEFT JOIN on o2.amount > o1.amount, 1 + COUNT(o2.order_id) | rank within customer | RANK() OVER (PARTITION BY customer_id ORDER BY amount DESC) |
Which of these self-joins has no window function equivalent?
One sort can serve many windows
The first tab pays twice: one hash of the table on user_id, a second on country. If the per-country figure can come from a GROUP BY, its few rows broadcast-join back to the sessions at a fraction of the cost of a second shuffle of the full table, and the window pass on user_id stays the only big one.
Filter the input or filter the output
| session_id | user_id | session_seq |
|---|---|---|
| 1 | u1 | 1 |
| 2 | u1 | 2 |
| 4 | u2 | 1 |
| 7 | u4 | 1 |
| 8 | u4 | 2 |
| session_id | user_id | session_seq |
|---|---|---|
| 1 | u1 | 1 |
| 2 | u1 | 2 |
| 4 | u2 | 2 |
| 7 | u4 | 1 |
| 8 | u4 | 2 |
Session 4 is u2's first long session on the left and u2's second session overall on the right, because the 45-second session 3 was removed before the numbering on the left and after it on the right. Both are correct answers to different questions, so choose the placement by the question and take the saving when it allows. A filter on a column that is constant within each partition, such as country for a window partitioned by user_id, removes whole partitions and changes no surviving value, so it always belongs before the window.
Skewed and single partitions
Partitions are the unit of parallelism, so their sizes set the wall-clock time. If PARTITION BY country sends 60% of a billion rows to one worker, that worker sorts 600M rows while the others finish and wait. OVER () with no partition and OVER (ORDER BY ts) with a global order put every row in 1 partition on 1 worker, turning a distributed sort into a single-machine one, and a global ROW_NUMBER over a billion rows is the most common way a window query runs out of memory.
- ▸Add a key: PARTITION BY country, DATE_TRUNC('day', ts) splits the big country into daily partitions, when the question can be answered per day.
- ▸Reduce first: a global top 100 is a per-partition top 100 in a CTE followed by a global rank over the few survivors.
- ▸Pre-aggregate: a share of grand total needs a window over one row per group, which a GROUP BY produces before the window runs.
How many shuffle-and-sort passes does this query need at minimum?
The 2 formulas
- CUME_DIST() = (rows with a value at or below this row's value) / N. It runs from 1/N for the lowest row to 1 for the highest.
- PERCENT_RANK() = (RANK - 1) / (N - 1). It runs from 0 for the lowest row to 1 for the highest, and a partition of 1 row gets 0.
| driver_id | weekly_pay | RANK | rows at or below | CUME_DIST | PERCENT_RANK |
|---|---|---|---|---|---|
| d1 | 400 | 1 | 1 | 1/6 = 0.167 | 0/5 = 0.0 |
| d2 | 500 | 2 | 3 | 3/6 = 0.5 | 1/5 = 0.2 |
| d3 | 500 | 2 | 3 | 3/6 = 0.5 | 1/5 = 0.2 |
| d4 | 700 | 4 | 4 | 4/6 = 0.667 | 3/5 = 0.6 |
| d5 | 900 | 5 | 5 | 5/6 = 0.833 | 4/5 = 0.8 |
| d6 | 1200 | 6 | 6 | 6/6 = 1.0 | 5/5 = 1.0 |
SELECTdriver_id,weekly_pay,CUME_DIST() OVER (ORDER BY weekly_pay) AS cume_dist,PERCENT_RANK() OVER (ORDER BY weekly_pay) AS pct_rankFROM driver_earnings
From a fraction to a percentile question
'Top 10% of drivers in their city' is a comparison on CUME_DIST over a city partition, and like any window output it has to be filtered or flagged in an outer query. Ordering descending flips the meaning: CUME_DIST() OVER (ORDER BY weekly_pay DESC) is the fraction of drivers earning at least this much, so the top earner gets 1/N and the top decile is cume_desc <= 0.1.
- A per-row position as a fraction of the partition
- Tied rows share one value
- Exact, computed from the sorted partition
- NTILE: a bucket number, and ties can split across 2 buckets
- APPROX_PERCENTILE: one value per group, the pay at the 90th percentile
- Use it to find a threshold, then compare rows to it
Choose by the question. 'Where does this driver stand' is CUME_DIST. 'What pay marks the 90th percentile' is APPROX_PERCENTILE(weekly_pay, 0.9) over the group, a single number. 'Split drivers into 4 equal groups for an experiment' is NTILE(4), which puts 2 tied drivers in different buckets when the split falls between them, something CUME_DIST never does.
Table t holds the values 10, 20, 20, 20, 30, 40, 50, 60. What is cd for each row where v = 20?
4 rules and their workarounds
| What you wrote | Why the engine refuses it | Workaround |
|---|---|---|
| WHERE ROW_NUMBER() OVER (...) = 1 | WHERE runs before the window exists | Window in a CTE, filter in the outer SELECT |
| LAG(SUM(x) OVER (...)) OVER (...) | A window cannot take another window as input | First window in a CTE, second window over the CTE's column |
| COUNT(DISTINCT device) OVER (PARTITION BY user_id) | DISTINCT inside a window function is not supported | Two DENSE_RANKs, or a GROUP BY joined back |
| GROUP BY ROW_NUMBER() OVER (...) | GROUP BY runs before the window | Window in a CTE, GROUP BY the CTE's column |
Aggregates inside windows are allowed
One nesting the engine accepts is an aggregate inside a window after GROUP BY. SUM(amount) is the group's total, and SUM(SUM(amount)) OVER () is the total across all groups, computed over the grouped rows rather than the raw table. That gives each group's share of the grand total in one query, and the window's single partition holds only as many rows as there are groups.
Read the inner SUM as done by GROUP BY and the outer SUM as done by the window over the 2 grouped rows. The empty OVER () that was a warning sign earlier, under skewed partitions, is harmless here: it covers 2 rows rather than 5, and on production data one row per customer rather than one per order.
Layering windows with CTEs
Ranking customers by their windowed total needs 2 windows where the second orders by the first's output. The engine rejects DENSE_RANK() OVER (ORDER BY SUM(amount) OVER (PARTITION BY customer_id)), so the first window goes into a CTE and the second reads its column like any other.
Each layer costs a sort of its own, one on customer_id and one on cust_total, which is the price of the second window and the reason to ask whether a GROUP BY would serve: ranking customers rather than orders needs only one row per customer, and DENSE_RANK() OVER (ORDER BY SUM(amount) DESC) after GROUP BY does it in a single query, because an aggregate inside a window is the nesting the engine allows.
> Rank customers by their total spend and keep every order of the top-ranked customer. Put the tiles in order so each window reads a column that already exists at its step and the filter comes after the rank. One tile does not belong in the query.
Distinct counts over a partition
COUNT(DISTINCT device) OVER (PARTITION BY user_id) throws an error, yet the question, how many different devices has this user logged in from, next to every login row, comes up constantly. DENSE_RANK answers it: ranked ascending by device, the last distinct device gets a rank equal to the distinct count; ranked descending, the first does. For any row, ascending rank plus descending rank minus 1 equals the number of distinct devices in the partition.
For u1's laptop row the ascending rank is 1 and the descending rank is 2, so 1 + 2 - 1 = 2; for either phone row it is 2 + 1 - 1 = 2. The 2 windows share a partition key and differ in order, so the trick costs 2 sorts of the table. When that is too much, or when the partition has thousands of rows per key, a GROUP BY user_id with COUNT(DISTINCT device) produces one small row per user that joins back to the login rows.
DENSE_RANK gives NULL a rank like any other value, so a NULL device adds 1 to the count. Filter NULLs out or COALESCE them before the window when they should not count.
Which of these queries runs without an error?
Exact duplicates and key duplicates
| payment_id | status | amount | ingested_at |
|---|---|---|---|
| p1 | pending | 50 | 2024-03-14 10:00:00 |
| p1 | settled | 50 | 2024-03-14 11:30:00 |
| p2 | settled | 80 | 2024-03-14 10:05:00 |
| p3 | pending | 20 | 2024-03-14 12:00:00 |
| p2 | settled | 80 | 2024-03-14 10:05:00 |
| p3 | pending | 20 | 2024-03-14 12:00:00 |
The table received 6 rows for 3 payments. The last 2 rows copy the p2 and p3 rows column for column: exact duplicates, the mark of a retried batch. The 2 p1 rows share a key and differ in status and ingested_at: key duplicates, 2 versions of one fact. DISTINCT removes the first kind and leaves the second untouched, because to DISTINCT the 2 p1 rows are different rows.
The gap between COUNT(*) and COUNT(DISTINCT key) is the number of rows dedup should remove, and it is the first number to compute, before and after, because a dedup that removes a different count has either missed copies or deleted real rows.
ROW_NUMBER picks one row per key
Partition by the business key, order so the row you want to keep comes first, number the rows, keep number 1. Ordering by ingested_at DESC keeps the newest version, which turns p1 from pending into settled; for p2 and p3 the 2 copies tie and either one is the right answer, since they are identical.
The query returns 3 rows, matching the distinct key count, and p1 carries its settled status. The rn = 1 filter sits in the outer query for the reason the previous section gave: inside the CTE the numbering has not happened yet when its own WHERE would run.
Ties decide whether the result is stable
When 2 copies of a key share the ordering value and differ elsewhere, ROW_NUMBER still hands out 1 and 2, in an order the engine does not promise to repeat. The same query can keep the pending row today and the settled row tomorrow, and a dashboard built on it moves without any data changing. The fix is an ORDER BY that cannot tie: add the source file name, a load sequence number, or a column that prefers the more complete row, such as status = 'settled' DESC. RANK is the wrong tool here because tied rows both get rank 1 and both survive the filter.
- Partition by the business key, never by the surrogate id the loader generated
- Order by a column that cannot tie, with a second key as the tie-breaker
- Compare COUNT(*) minus COUNT(DISTINCT key) before and after the dedup
- Dedup at the table's grain: one row per payment, or per payment per status change
- Rely on DISTINCT when any column can differ between copies
- Use RANK or DENSE_RANK to pick one row, since ties keep more than one
- Leave the tie-break to the engine and expect the same row back tomorrow
- Dedup downstream in every consumer instead of once in the curated table
Payment p9 arrived twice with the same ingested_at, once with status pending and once with status settled. What does this query return for p9?
> A ride-hailing marketplace closes its weekly driver payout. Trip events arrive from a queue that retries on timeout, so the raw table holds copies; the driver statement shows running earnings for the week and the driver's standing against others in the city; the finance dashboard shows each city's share of the global payout.
Replace self-joins, rank by percentile, work around engine limits, and dedupe production tables with windows.
One pass to rule them all
- Category
- SQL
- Difficulty
- advanced
- Duration
- 41 minutes
- Challenges
- 19 hands-on challenges
Topics covered: Windows vs Self-Joins, Windows and Partitioning, Cumulative Distribution, Workarounds & Engine Limits, Deduplication
Lesson Sections
- Windows vs Self-Joins (concepts: Self-Join)
A self-join pairs each row of a table with other rows of the same table, and for years it was the only way to put a row next to its own neighbours: the previous order, every earlier order, the customer's total. Each of those questions compares a row with other rows that share a key, which is a window partition, and a window answers it without building a single pair. The same running total, 2 ways Take 5 orders from 2 customers and compute a running total per customer by date. The inequality self
- Windows and Partitioning (concepts: Window Partitioning)
The engine runs a window function in 3 physical steps: it hashes every row by the PARTITION BY columns so rows sharing a key land on the same worker, sorts each partition by the ORDER BY columns, and streams the sorted rows through the function. The first step is a shuffle, the second a sort, and on a large table both cost more than the function itself, so a fast window query is one that runs those 2 steps as few times as possible over as few rows as possible. One sort can serve many windows The
- Cumulative Distribution (concepts: Cumulative Distribution)
RANK says a driver is 5th; CUME_DIST says the driver earns at least as much as 83% of drivers. The second number is the one a statement or a dashboard shows, because it means the same thing in a city of 60 drivers and a city of 60,000. CUME_DIST and PERCENT_RANK both compute it, and they differ only in how they count the current row and its ties. The 2 formulas Both functions sort the partition by the ORDER BY columns and give tied rows the same value. CUME_DIST counts the whole tie group as bei
- Workarounds & Engine Limits (concepts: Execution Order)
Window functions are evaluated in the SELECT step, after FROM, WHERE, GROUP BY and HAVING and before ORDER BY and LIMIT. Most of the errors they throw follow from that position, and so do the workarounds: anything the engine refuses in one step can be computed in a CTE and consumed in the next. 4 rules and their workarounds Aggregates inside windows are allowed Layering windows with CTEs Distinct counts over a partition DENSE_RANK gives NULL a rank like any other value, so a NULL device adds 1 t
- Deduplication (concepts: Window Deduplication)
Production tables collect duplicates from the machinery that fills them: a pipeline run retried after a timeout writes its batch twice, a message queue delivers a payment event at least once rather than exactly once, a backfill overlaps the day the daily load already wrote. The rows are wrong in a way no single row reveals, so dedup is a question about groups of rows that share a key, which is a window's partition. Exact duplicates and key duplicates ROW_NUMBER picks one row per key Ties decide