Module 6 — Window Functions
Why window functions exist
Go back to Module 3. You already know how to get "total revenue per customer" — that's GROUP BY. It collapses many rows into one row per group. You lose the individual rows.
But now ask a different question: "show me every single order, and next to each one, what percentage of that customer's total revenue does it represent?"
You can't answer that with GROUP BY. The moment you group by customer, the individual order rows are gone — averaged, summed, or grouped away. You need the detail row and the aggregate, at the same time, on the same line.
That is the one-sentence reason window functions exist: they let you compute an aggregate (or a rank, or a "look at the next row") without collapsing the rows you're computing it over.
GROUP BY is the class average printed once at the bottom of the page — one number, all individual student rows are gone. A window function is a column added next to every student's own row that shows "class average" or "your rank in class" — the student's row survives, and the aggregate rides along beside it. The "window" is the group of rows (the whole class, or just the students in Section A) that the calculation is allowed to "look at" while computing that value for one row.
When to use it
- You need detail rows and an aggregate/rank/comparison in the same result set (rank, running total, % of total, comparison to previous row).
- Top-N-per-group problems ("top 3 highest paid employees per department").
- Time-series analysis: running totals, moving averages, period-over-period change.
- Deduplication: keep exactly one row per group based on some rule (latest record, highest value).
- Gap detection, streak detection, sessionization — anything where "compare this row to the row before/after it" matters.
When NOT to use it
- If you only need one aggregated row per group and don't need the detail rows — plain
GROUP BYis simpler and usually cheaper. - If you need to filter on the aggregate directly in the same query without a wrapping query — you cannot put a window function in
WHERE(more on this trap later). - Very old database versions without window function support (rare today, but some legacy MySQL < 8.0 setups still exist) — you'd fall back to correlated subqueries or variables.
How it executes internally
Conceptually, the engine runs window functions after WHERE, GROUP BY, and HAVING have already produced the row set, but before the final ORDER BY and SELECT list are applied. For each row, it:
- Figures out which other rows belong to the same "window" (the
PARTITION BYgroup, or the whole result set if there's no partition). - Within that window, sorts by
ORDER BYif one is given. - Applies the frame clause (which subset of the partition — e.g. "from the start up to this row") if one is given.
- Computes the function (rank, sum, lag, etc.) over exactly that frame, and attaches the result to the current row — without deleting any other rows.
Schema & sample data
Every example in this module reuses one small orders table so you can hold the data in your head instead of re-learning a schema every section.
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT NOT NULL,
order_date DATE NOT NULL,
amount NUMERIC(10,2) NOT NULL,
status VARCHAR(20)
);
| order_id | customer_id | order_date | amount | status |
|---|---|---|---|---|
| 1 | 101 | 2024-01-03 | 250.00 | completed |
| 2 | 101 | 2024-01-10 | 90.00 | completed |
| 3 | 101 | 2024-02-01 | 400.00 | completed |
| 4 | 102 | 2024-01-05 | 120.00 | completed |
| 5 | 102 | 2024-01-06 | 75.00 | refunded |
| 6 | 103 | 2024-01-20 | 600.00 | completed |
Wherever a section needs a different shape of data — clickstream events for sessionization, login dates for streaks, user signup dates for cohorts — the example introduces a small extra table locally so nothing is hidden from you.
Anatomy of OVER()
Every window function has the same skeleton. Learn this skeleton once and every function in this module is just a different word plugged into the first slot.
function_name(expr) OVER (
PARTITION BY partition_column(s) -- optional: splits rows into groups
ORDER BY sort_column(s) -- optional: defines row order inside each group
ROWS/RANGE BETWEEN ... AND ... -- optional: defines the frame (subset of the group)
)
Three independent knobs, and each one can be present or absent on its own:
- PARTITION BY — which rows are "in the same window" as the current row.
- ORDER BY — the sequence used inside the window (needed for rank, lag/lead, running totals).
- Frame clause — which slice of the ordered partition the function actually sees (defaults to something surprising — see the Frame Clauses section).
What happens if PARTITION BY is missing
The entire result set becomes one single window. SUM(amount) OVER () with no PARTITION BY puts the grand total on every single row.
SELECT order_id, amount,
SUM(amount) OVER () AS grand_total
FROM orders;
PARTITION BY on a huge table means the engine sorts/scans the entire table as one window — this is the classic "why is my query suddenly slow" trap covered again in Performance Topics.What happens if window ORDER BY is missing
For pure aggregates (SUM, AVG, COUNT, MIN, MAX) with no ORDER BY, the frame defaults to the entire partition — you get one aggregate value repeated on every row of that partition (a "total", not a "running total").
For ranking and navigation functions (ROW_NUMBER, RANK, LAG, LEAD) an ORDER BY is effectively mandatory — without it the row order is undefined and the result is nondeterministic garbage that happens to "look" consistent on small test data.
Query ORDER BY vs window ORDER BY
Window ORDER BY (inside OVER(...)) controls how the function computes its value — it decides row #1, row #2, etc. inside each partition, and it has no effect on the order rows are displayed in.
Query ORDER BY (at the very end of the statement) controls the order the final result set is displayed to the client. It's a completely separate, unrelated sort.
ROW_NUMBER() OVER (ORDER BY amount). They don't — you still need a final ORDER BY rn if you want the display order to match the computed rank.Multiple window functions in one query
You can stack as many window functions as you want in the same SELECT, each with its own independent OVER(...) spec:
SELECT order_id, customer_id, amount,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) AS order_seq,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total,
LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_amount
FROM orders;
Each function is evaluated independently — they don't interact with or depend on each other, even inside the same SELECT list.
PARTITION BY vs GROUP BY
Collapses many rows into one row per group. The detail rows are gone — you only get the aggregate.
Keeps every row. It just tells the window function which other rows count as "the same group" when computing its value — the row count of the result never changes.
Example: customer total beside every order
-- GROUP BY: one row per customer, detail lost
SELECT customer_id, SUM(amount) AS customer_total
FROM orders
GROUP BY customer_id;
-- PARTITION BY: every order row survives, total riding alongside
SELECT order_id, customer_id, amount,
SUM(amount) OVER (PARTITION BY customer_id) AS customer_total
FROM orders;
Percent of customer total
SELECT order_id, customer_id, amount,
ROUND(100.0 * amount / SUM(amount) OVER (PARTITION BY customer_id), 1) AS pct_of_customer
FROM orders;
Percent of grand total
SELECT order_id, customer_id, amount,
ROUND(100.0 * amount / SUM(amount) OVER (), 1) AS pct_of_grand_total
FROM orders;
PARTITION BY is like sorting a deck of cards into piles by suit but leaving every card face-up on the table — you can still see each individual card, you've just organized which pile it "belongs to" for counting purposes. GROUP BY is shuffling each pile into a single summary card and throwing the originals away.Ranking functions: ROW_NUMBER, RANK, DENSE_RANK, NTILE
SELECT customer_id, amount,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn,
RANK() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS drnk,
NTILE(4) OVER (PARTITION BY customer_id ORDER BY amount DESC) AS quartile
FROM orders;
Tie behavior — the single most-tested distinction
| amount | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 400 | 1 | 1 | 1 |
| 400 | 2 | 1 | 1 |
| 250 | 3 | 3 | 2 |
| 90 | 4 | 4 | 3 |
- ROW_NUMBER() — always unique, 1,2,3,4. Ties are broken arbitrarily (or nondeterministically) unless you add a tie-breaker column.
- RANK() — ties share the same rank, and the next rank skips (1,1,3,4).
- DENSE_RANK() — ties share the same rank, but the next rank does not skip (1,1,2,3).
- NTILE(n) — splits the partition into
nroughly-equal-sized buckets (1..n), used for quartiles/percentile buckets. If rows don't divide evenly, earlier buckets get the extra rows.
Deterministic ordering with tie-breaker columns
ORDER BY column, ROW_NUMBER() is free to assign 1 and 2 in either order — and that order can change between runs, especially after the optimizer picks a different plan. Always add a unique tie-breaker: ORDER BY amount DESC, order_id DESC.Top-N per group
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC, order_id) AS rn
FROM orders
) x
WHERE rn <= 3;
Pagination
-- "Page 3" of 20-row pages, ordered by order_date
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY order_date, order_id) AS rn
FROM orders
) x
WHERE rn BETWEEN 41 AND 60;
Percentile buckets with NTILE
SELECT customer_id, amount,
NTILE(4) OVER (ORDER BY amount) AS quartile -- 1 = lowest 25%, 4 = highest 25%
FROM orders;
Value functions: FIRST_VALUE, LAST_VALUE, NTH_VALUE
SELECT customer_id, order_date, amount,
FIRST_VALUE(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS first_order_amount,
LAST_VALUE(amount) OVER (PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS last_order_amount,
NTH_VALUE(amount, 2) OVER (PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS second_order_amount
FROM orders;
With a window ORDER BY and no explicit frame, the default frame is "start of partition to current row" — not the whole partition. That means LAST_VALUE() without an explicit frame just returns the current row's own value, which looks like a bug to nearly everyone the first time they see it.
The correct fix: full-partition frame
LAST_VALUE(amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
FIRST_VALUE() doesn't have this problem in the same way, because "first row of the default frame" and "first row of the partition" are the same thing — but it's good habit to state the frame explicitly for all three of these functions so the query means the same thing on every engine.
NTH_VALUE
NTH_VALUE(expr, n) returns the value from the n-th row of the frame — e.g. the 2nd order's amount. Like LAST_VALUE, it needs the full-partition frame to behave predictably when n might fall after the current row.
Frame clauses: ROWS, RANGE, GROUPS
The frame clause is the third knob on OVER(...) — it narrows the partition down to the exact subset of rows the function is allowed to look at, relative to the current row.
... OVER (
PARTITION BY ...
ORDER BY ...
{ROWS | RANGE | GROUPS} BETWEEN frame_start AND frame_end
)
- UNBOUNDED PRECEDING — start of the partition.
- CURRENT ROW — the row being evaluated.
- UNBOUNDED FOLLOWING — end of the partition.
- n PRECEDING / n FOLLOWING — a fixed number of rows/values before or after.
Default frame behavior
ORDER BY but no explicit frame, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — "everything from the start of the partition through the current row's peer group." This is what silently turns an intended "total" into a "running total," and what breaks LAST_VALUE() (see Value Functions).ROWS vs RANGE — the difference that bites in production
Counts physical rows. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW always means exactly 7 rows, regardless of whether their ORDER BY values are unique.
Counts by value, not row position. All rows that tie on the ORDER BY value are treated as one "peer group" and included or excluded together — the number of physical rows in the frame can vary.
GROUPS
GROUPS (supported on newer Postgres, Snowflake, BigQuery) is like ROWS but counts distinct peer groups instead of individual rows — GROUPS BETWEEN 2 PRECEDING AND CURRENT ROW means "the current peer group plus the two peer groups before it," useful when ties are common and you still want row-like counting.
Duplicate ORDER BY values and peer rows
If two orders share the exact same order_date, a RANGE frame treats them as arriving "at the same instant" — a running-total-by-RANGE over dates will jump both rows' totals up together rather than one-at-a-time. This is precisely why ROWS is the safer, more predictable default for running totals in production — see the warning below.
RANGE/GROUPS support. Writing the frame out explicitly — even when it matches the default — makes the query's behavior portable and self-documenting instead of relying on a rule your teammate (or future you) has to remember.Filtering window results: subquery, CTE, QUALIFY
WHERE in logical query processing order — so you can never reference a window function's alias inside the same query's WHERE clause. This is one of the most common runtime errors beginners hit.-- ❌ fails: "column rn does not exist" / invalid use of window function
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
FROM orders
WHERE rn = 1;
Fix 1 — wrap it in a subquery
SELECT *
FROM (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
FROM orders
) x
WHERE rn = 1;
Fix 2 — wrap it in a CTE (same idea, more readable)
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
FROM orders
)
SELECT * FROM ranked WHERE rn = 1;
Fix 3 — QUALIFY (BigQuery, Snowflake)
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
FROM orders
QUALIFY rn = 1;
QUALIFY is to window functions what HAVING is to GROUP BY aggregates — a dedicated filter stage that runs after the window function is computed, without the ceremony of a wrapping subquery or CTE. It's not available in PostgreSQL, SQL Server, or MySQL — use the subquery/CTE pattern there.
Running totals & aggregate windows
The same five aggregates you already know from GROUP BY — SUM, AVG, COUNT, MIN, MAX — become window functions the moment you add OVER(...).
Running totals
SELECT customer_id, order_date, amount,
SUM(amount) OVER (
PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
Running averages
AVG(amount) OVER (
PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_avg
Grand totals vs per-group totals
SELECT order_id, customer_id, amount,
SUM(amount) OVER () AS grand_total, -- no PARTITION BY
SUM(amount) OVER (PARTITION BY customer_id) AS per_customer_total -- no ORDER BY = whole partition
FROM orders;
Percent of total
SELECT order_id, customer_id, amount,
ROUND(100.0 * amount / SUM(amount) OVER (PARTITION BY customer_id), 1) AS pct_of_customer_total
FROM orders;
ORDER BY = "total for the group, same value on every row." Aggregate + ORDER BY + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW = "running total up to this row." Confusing the two is the #1 window-function bug in real codebases.Moving averages
A moving average is a running average with a bounded window instead of an unbounded one — "the last 7 rows" instead of "everything so far."
-- 7-day (row-based) moving average
SELECT customer_id, order_date, amount,
AVG(amount) OVER (
PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7
FROM orders;
3-day and 30-day versions are the identical pattern with 2 PRECEDING and 29 PRECEDING respectively (n rows back + the current row = n+1 total rows).
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW means "the 7 most recent rows that exist" — not "the 7 most recent calendar days." If a customer has no orders on several days, a "7-day" row-based average can silently span 3 weeks of real time.
Why you need a calendar/date spine in production
To get a true calendar-based moving average, first generate one row per day (a "date spine") per entity — even days with zero activity — then compute the ROWS-based average over that gap-free series, or switch to a RANGE frame with an interval (RANGE BETWEEN INTERVAL '6 days' PRECEDING AND CURRENT ROW on engines that support date-typed RANGE).
-- Postgres-style date spine
SELECT d::date AS day
FROM generate_series('2024-01-01'::date, '2024-01-31'::date, '1 day') d;
Then LEFT JOIN real activity onto the spine so every day is represented (with 0 or NULL where nothing happened) before windowing.
Period-over-period analysis
Daily / monthly change
SELECT order_date, daily_revenue,
daily_revenue - LAG(daily_revenue) OVER (ORDER BY order_date) AS change_vs_prev_day
FROM (
SELECT order_date, SUM(amount) AS daily_revenue
FROM orders GROUP BY order_date
) daily;
Monthly change is the same query with DATE_TRUNC('month', order_date) (or your engine's equivalent) in place of the raw date.
Revenue growth %
SELECT order_date, daily_revenue,
ROUND(100.0 * (daily_revenue - LAG(daily_revenue) OVER (ORDER BY order_date))
/ NULLIF(LAG(daily_revenue) OVER (ORDER BY order_date), 0), 1) AS pct_growth
FROM (
SELECT order_date, SUM(amount) AS daily_revenue
FROM orders GROUP BY order_date
) daily;
NULLIF(x, 0) turns a division-by-zero error into a clean NULL — important because LAG can legitimately return 0 or NULL for the first period.Handling the first row with no previous period
LAG() returns NULL for the first row of each partition by design — there is no prior period to compare against. Decide deliberately whether that should stay NULL, become 0 via LAG(x, 1, 0), or be filtered out downstream; don't let it silently propagate into a growth-% calculation as an unhandled NULL.
Deduplication with ROW_NUMBER
This is the single most common window-function pattern in real ETL pipelines: keep exactly one row per key, dropping the rest.
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY updated_at DESC, order_id DESC) AS rn
FROM orders_staging
)
SELECT * FROM ranked WHERE rn = 1;
Keep the latest row per key
PARTITION BY the natural key, ORDER BY the recency column descending, keep rn = 1. This is the exact same shape whether the key is a customer, a product, or an event ID.
Delete duplicates using ROW_NUMBER
DELETE FROM orders_staging
WHERE order_id IN (
SELECT order_id FROM (
SELECT order_id,
ROW_NUMBER() OVER (PARTITION BY customer_id, order_date ORDER BY order_id DESC) AS rn
FROM orders_staging
) x
WHERE rn > 1
);
Dedup staging before MERGE
A MERGE/upsert statement will error or silently misbehave if the source side contains multiple rows matching the same target key. Standard production pattern: dedup the staging table (or a CTE feeding the merge) with ROW_NUMBER() = 1 before the MERGE ever runs.
Deterministic tie-breakers
Same rule as ranking functions: ORDER BY updated_at DESC alone can tie. Always append a unique column (order_id, a surrogate key, or the raw file's row-ingestion timestamp) so which row survives is reproducible, not luck-of-the-plan.
DELETE against a production table: (1) run the SELECT version first and eyeball the row count being removed, (2) copy the rows-to-be-deleted into an audit table, (3) run large deletes in batches inside a transaction so you can roll back if the count looks wrong.Top-N per group
Top 1 per group
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC, order_id) AS rn
FROM orders
) x WHERE rn = 1;
Top 3 per group
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC, order_id) AS rn
FROM orders
) x WHERE rn <= 3;
Ties: RANK() vs exact N with ROW_NUMBER()
Use RANK() and filter rnk <= 3 — if two rows tie for 3rd place, you legitimately get 4 rows back. This is what "top 3 salaries" usually means in a business sense.
Use ROW_NUMBER() with a deterministic tie-breaker and filter rn <= 3 — guaranteed exactly 3 rows per group, ties broken by the tie-breaker column.
Worked examples
-- Top 3 highest-paid employees per department
SELECT * FROM (
SELECT employee_id, department_id, salary,
DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS drnk
FROM employees
) x WHERE drnk <= 3;
-- Top product per customer by total spend
SELECT * FROM (
SELECT customer_id, product_id, SUM(amount) AS spend,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY SUM(amount) DESC) AS rn
FROM orders GROUP BY customer_id, product_id
) x WHERE rn = 1;
Gaps and islands
"Islands" are runs of consecutive values (consecutive dates, consecutive IDs). "Gaps" are the missing values between islands. The classic trick: subtract a ROW_NUMBER() from the date/sequence — rows in the same consecutive run land on the exact same constant value, which becomes your grouping key.
Consecutive date streaks / consecutive login days
WITH numbered AS (
SELECT user_id, login_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn
FROM logins
),
islands AS (
SELECT user_id, login_date,
login_date - (rn * INTERVAL '1 day') AS island_key -- constant within a streak
FROM numbered
)
SELECT user_id, island_key, COUNT(*) AS streak_length,
MIN(login_date) AS streak_start, MAX(login_date) AS streak_end
FROM islands
GROUP BY user_id, island_key
ORDER BY user_id, streak_start;
Why it works: within an unbroken run of consecutive days, login_date increases by exactly 1 each row and rn also increases by exactly 1 — so login_date - rn stays constant for the whole run and jumps the instant a day is skipped.
Grouping islands using date − ROW_NUMBER()
The pattern generalizes beyond dates: for any strictly-increasing integer sequence, sequence_value - ROW_NUMBER() is constant within a consecutive island and different across islands.
Detecting missing sequence numbers
SELECT order_id AS gap_start_after,
LEAD(order_id) OVER (ORDER BY order_id) - order_id - 1 AS missing_count
FROM orders
QUALIFY LEAD(order_id) OVER (ORDER BY order_id) - order_id > 1;
Detecting inactive periods
SELECT customer_id, order_date,
order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS days_since_last_order
FROM orders
QUALIFY order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) > 30;
Sessionization
Turn a raw stream of timestamped events into "sessions" — groups of events from the same user separated by a gap of inactivity (commonly 30 minutes in web analytics).
WITH events_with_gap AS (
SELECT user_id, event_time,
EXTRACT(EPOCH FROM (event_time - LAG(event_time) OVER (
PARTITION BY user_id ORDER BY event_time))) / 60 AS gap_minutes
FROM clickstream
),
session_starts AS (
SELECT *,
CASE WHEN gap_minutes IS NULL OR gap_minutes > 30 THEN 1 ELSE 0 END AS is_new_session
FROM events_with_gap
),
sessionized AS (
SELECT *,
SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_id
FROM session_starts
)
SELECT user_id, session_id, event_time FROM sessionized ORDER BY user_id, event_time;
How it works, step by step
- LAG() compares each event's timestamp to the previous event from the same user.
- If the gap exceeds 30 minutes (or it's the user's very first event), flag it as
is_new_session = 1— a "session-start flag." - A running SUM() of that flag, in event order, produces a number that only increments at session boundaries — that running sum is the session ID.
Production use case: web analytics
This exact three-step pattern (gap detection → flag → running sum) is how most web analytics pipelines (Snowplow-style event pipelines, GA4-style session stitching) derive session IDs from raw clickstream events before any downstream funnel or bounce-rate analysis can run.
Cohort & retention analysis
A cohort analysis groups users by when they started (their cohort month), then measures how many are still active in each month afterward. It's window functions (for the "when did they start" part) followed by ordinary aggregation.
WITH first_activity AS (
SELECT user_id,
MIN(activity_date) OVER (PARTITION BY user_id) AS first_activity_date
FROM user_activity
),
labeled AS (
SELECT user_id, activity_date,
DATE_TRUNC('month', first_activity_date) AS cohort_month,
DATE_PART('month', AGE(DATE_TRUNC('month', activity_date), DATE_TRUNC('month', first_activity_date))) AS month_offset
FROM first_activity
)
SELECT cohort_month, month_offset, COUNT(DISTINCT user_id) AS active_users
FROM labeled
GROUP BY cohort_month, month_offset
ORDER BY cohort_month, month_offset;
The pieces
- First activity date per user —
MIN(activity_date) OVER (PARTITION BY user_id), so every row for that user carries their signup/first-activity date alongside it. - Cohort month — truncate the first-activity date to the month; every user gets bucketed into exactly one cohort.
- Activity month offset — the difference in months between the current activity's month and the cohort month (0 = signup month, 1 = one month later, etc.), typically via
DATE_DIFF/AGE. - Retention matrix — after windowing derives cohort month and offset per row, a final
GROUP BY cohort_month, month_offsetaggregation produces the classic triangular retention table (rows = cohorts, columns = months since signup).
GROUP BY aggregates on top of it. Cohort analysis is the textbook example of chaining window functions into a regular aggregation.Slowly changing / latest-snapshot patterns
Many source systems only ever append new versions of a row instead of updating it (append-only change logs, CDC feeds, audit-style history tables). To answer "what does this entity look like right now," you need the latest row per entity — the exact dedup pattern from earlier, applied to a different problem framing.
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY updated_at DESC) AS rn
FROM entity_history
)
SELECT * FROM ranked WHERE rn = 1;
Latest record / current active row
Same shape as deduplication: PARTITION BY the entity's natural key, ORDER BY updated_at DESC, keep rn = 1. The difference from a pure dedup task is intent — here you're deliberately modeling "current state" as a view on top of a full history table, not discarding history.
Why this matters before joining dimensions
ROW_NUMBER() = 1 before the join, or explicitly join on an effective-date range if you need point-in-time correctness.PERCENT_RANK, CUME_DIST, and quartiles
SELECT customer_id, amount,
PERCENT_RANK() OVER (ORDER BY amount) AS pct_rank, -- (rank-1) / (n-1), range 0..1
CUME_DIST() OVER (ORDER BY amount) AS cume_dist, -- rows <= current / n, range >0..1
NTILE(4) OVER (ORDER BY amount) AS quartile
FROM orders;
PERCENT_RANK()
Returns the relative rank of the current row as a fraction between 0 and 1: (rank - 1) / (total_rows - 1). The lowest value in the partition is always exactly 0; the highest is always exactly 1.
CUME_DIST()
"Cumulative distribution" — the fraction of rows in the partition whose value is less than or equal to the current row's value. Unlike PERCENT_RANK, it never returns 0 (every row is at least "less than or equal to" itself) and ties share the same value.
Median / percentile discussion
None of the ranking functions directly compute a median or an arbitrary percentile value — for that, most engines offer a dedicated function outside the window-ranking family (PERCENTILE_CONT/PERCENTILE_DISC as ordered-set aggregates, or APPROX_PERCENTILE for large-scale approximations in Snowflake/BigQuery/Spark). PERCENT_RANK/CUME_DIST tell you where a given row sits in the distribution; PERCENTILE_CONT tells you what value sits at a given percentile.
NTILE(4) for quartiles
The simplest practical outlier-flagging pattern in interviews: bucket into quartiles with NTILE(4), then treat quartile 1 and quartile 4 as "low outlier candidates" and "high outlier candidates" for a follow-up filter.
Window function performance
Sort cost
Window functions almost always require sorting the data by PARTITION BY + ORDER BY before the function can be computed. On a large table this sort is often the single most expensive operation in the whole query plan — more expensive than the scan itself.
Indexes for window functions
CREATE INDEX idx_orders_customer_date ON orders(customer_id, order_date);
- Index the
PARTITION BYcolumn(s) first, then the windowORDER BYcolumn(s) — a composite index in that order lets the engine potentially read rows already sorted, skipping an explicit sort step. - A covering index (one that also includes the selected/aggregated columns) can let the engine satisfy the whole window computation from the index alone, without touching the base table.
Memory spills
Pre-filtering rows with a WHERE clause before the window function runs (rather than filtering afterward with QUALIFY/subquery) shrinks the data being sorted and partitioned, directly reducing spill risk.
Partition size & skew
One enormous partition is expensive regardless of how small the others are — if one customer represents 90% of all rows, that single partition dominates the total sort/compute cost even though it's "just one group" logically. This mirrors the same skew problem you'll see in GROUP BY and JOIN performance work.
Multiple windows: reuse with a named WINDOW clause
SELECT customer_id, order_date, amount,
SUM(amount) OVER w AS running_total,
AVG(amount) OVER w AS running_avg,
COUNT(*) OVER w AS running_count
FROM orders
WINDOW w AS (PARTITION BY customer_id ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW);
Reusing a named window lets the engine potentially sort the data once and stream all three functions off that single sort. Functions with genuinely different PARTITION BY/ORDER BY specs still force separate sorts — named windows can't merge fundamentally different orderings.
Pre-aggregation
GROUP BY into a smaller intermediate result), then apply the window function on top of the smaller table — don't run window functions directly over billions of raw event rows when a daily summary would answer the same question.Distributed warehouse notes
In BigQuery, Snowflake, and Spark, a window function's PARTITION BY key isn't just a logical grouping — it determines how data gets shuffled across worker nodes before the computation can run, because all rows for a given partition key must land on the same node.
- Partition key affects data movement — a well-chosen key (e.g.
customer_idwith many distinct, evenly-sized customers) shuffles cleanly; a poor key concentrates work on one node. - Skew in window partitions — the distributed-systems version of the "one customer has 90% of rows" problem: that node becomes a straggler that the whole job waits on.
- Cost of global windows with no PARTITION BY — an unpartitioned window (e.g.
SUM(x) OVER ()) effectively forces every row onto a single node/reducer for that computation, which on a genuinely large table can be dramatically slower or even infeasible.
PARTITION BY key's cardinality and skew before assuming the frame or function itself is the problem.Dialect differences
| Engine | Notes |
|---|---|
| PostgreSQL | Strong, standards-compliant window support; named windows; FILTER clause usable alongside window aggregates (SUM(x) FILTER (WHERE cond) OVER (...)); no QUALIFY. |
| SQL Server | Full window function support; some frame syntax (e.g. certain RANGE forms) was limited in older versions (pre-2012 lacked windowing almost entirely); SELECT TOP ... WITH TIES is a common non-window alternative for "top N with ties." |
| MySQL | Window functions only exist from MySQL 8.0 onward — earlier versions require workarounds (session variables, self joins). No QUALIFY. |
| BigQuery | Full analytic/window support; supports QUALIFY. |
| Snowflake | Rich window function support; supports QUALIFY; commonly used for exactly the dedup/latest-row patterns above. |
| Oracle | Calls these "analytic functions"; heavily used historically; unique KEEP (DENSE_RANK FIRST/LAST) OVER (...) syntax for "value from the row with the first/last rank," a pattern with no direct equivalent in most other dialects. |
QUALIFY (Snowflake/BigQuery) will not run unmodified on Postgres, SQL Server, or MySQL — always fall back to the subquery/CTE filtering pattern if the target engine is unknown or mixed.How interviewers trick you
These are the exact conceptual gaps interviewers probe for. If you can explain each one out loud in one sentence, you're in good shape.
1.ROW_NUMBER vs RANK vs DENSE_RANK▶
All three number rows by an ORDER BY. ROW_NUMBER never ties (always unique 1,2,3...). RANK ties and then skips the next number. DENSE_RANK ties and does not skip. Say the tie behavior out loud — that's what they're testing.
2.Why WHERE rn = 1 doesn't work in the same query▶
Logical query processing evaluates WHERE before window functions, so the alias doesn't exist yet at that stage. Wrap in a subquery/CTE, or use QUALIFY where supported.
3.Why LAST_VALUE gives a surprising result▶
The default frame with an ORDER BY is "start of partition to current row," so LAST_VALUE() without an explicit frame just returns the current row. Fix with ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
4.Difference between ROWS and RANGE▶
ROWS counts physical rows regardless of ties. RANGE counts by value — tied rows form one peer group that moves in and out of the frame together.
5.Why a missing tie-breaker makes results nondeterministic▶
If the ORDER BY column has duplicate values, the engine is free to break the tie however its current execution plan happens to — which can change between runs or after a statistics update. Always append a unique column to the ORDER BY.
6.Window ORDER BY vs final ORDER BY▶
Window ORDER BY (inside OVER(...)) only controls the calculation. It has zero effect on the order the rows are returned in — you still need a query-level ORDER BY for display order.
7.Why GROUP BY and window functions can conflict▶
Window functions run after GROUP BY/HAVING in logical order, so a window function can reference an already-grouped/aggregated column, but a plain (non-aggregated, non-grouped) column can't appear alongside a GROUP BY unless it's wrapped in an aggregate or a window function itself.
8.Why COUNT(*) OVER() gives total rows on every row▶
No PARTITION BY means the entire result set is one window, and no ORDER BY means the frame defaults to the whole partition — so every row sees the same count: the total row count of the result set.
3 hard interview questions
Q1.Find the second-highest salary per department Hard▶
SELECT department_id, salary FROM (
SELECT department_id, salary,
DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS drnk
FROM employees
) x WHERE drnk = 2;
DENSE_RANK is the correct choice here (not ROW_NUMBER) because "second highest salary" should mean the second distinct value, even if multiple employees share the top salary.
Q2.Find customers whose current order is higher than their previous order Hard▶
SELECT * FROM (
SELECT customer_id, order_id, order_date, amount,
LAG(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_amount
FROM orders
) x
WHERE amount > prev_amount;
Classic LAG() + filter. The trap: this must be wrapped, exactly like the WHERE rn = 1 case, because the comparison needs the window function's result already computed.
Q3.Find the longest login streak per user Hard▶
WITH numbered AS (
SELECT user_id, login_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rn
FROM logins
),
islands AS (
SELECT user_id, login_date - (rn * INTERVAL '1 day') AS island_key
FROM numbered
)
SELECT user_id, MAX(streak_len) AS longest_streak FROM (
SELECT user_id, island_key, COUNT(*) AS streak_len
FROM islands GROUP BY user_id, island_key
) s
GROUP BY user_id;
This is the gaps-and-islands pattern (subtract ROW_NUMBER from the date) followed by a normal GROUP BY/MAX to find the longest island per user.
2 FAANG questions
Q1.Sessionize a raw clickstream and report average session length FAANG▶
WITH flagged AS (
SELECT user_id, event_time,
CASE WHEN LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) IS NULL
OR event_time - LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) > INTERVAL '30 minutes'
THEN 1 ELSE 0 END AS is_new_session
FROM clickstream
),
sessioned AS (
SELECT *, 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
),
session_bounds AS (
SELECT user_id, session_id, MIN(event_time) AS start_t, MAX(event_time) AS end_t
FROM sessioned GROUP BY user_id, session_id
)
SELECT AVG(EXTRACT(EPOCH FROM (end_t - start_t))) AS avg_session_seconds
FROM session_bounds;
Full sessionization (from the Data Engineering section) followed by a standard GROUP BY/AVG — this exact two-stage shape (window functions to derive a per-row attribute, then aggregate on top of it) is the single most reused pattern in FAANG-level SQL interviews.
Q2.Build a month-1/month-2/month-3 retention table by signup cohort FAANG▶
WITH first_activity AS (
SELECT user_id, MIN(activity_date) OVER (PARTITION BY user_id) AS signup_date
FROM user_activity
),
labeled AS (
SELECT user_id, activity_date,
DATE_TRUNC('month', signup_date) AS cohort_month,
(EXTRACT(YEAR FROM activity_date) * 12 + EXTRACT(MONTH FROM activity_date))
- (EXTRACT(YEAR FROM signup_date) * 12 + EXTRACT(MONTH FROM signup_date)) AS month_offset
FROM first_activity
)
SELECT cohort_month, month_offset, COUNT(DISTINCT user_id) AS active_users
FROM labeled
WHERE month_offset BETWEEN 0 AND 3
GROUP BY cohort_month, month_offset
ORDER BY cohort_month, month_offset;
This is the cohort/retention pattern from the Data Engineering section, restated as a full standalone question — the exact structure a FAANG analytics-engineering interview would ask for.
Production safety & correctness
Deterministic ordering
Always add a tie-breaker to any ORDER BY used inside a window function that decides which row "wins" (dedup, latest-snapshot, top-N).
ORDER BY updated_at DESC, id DESC
Dedup safety
- Preview the rows that would be removed with a
SELECTbefore ever running theDELETE. - Store the rows being removed in an audit table first, so a mistake is recoverable.
- Batch large deletes instead of one massive statement, and wrap in a transaction so you can roll back.
Missing dates
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW is not the same as a 7-calendar-day average unless every calendar day has a row. Build a date spine when calendar-day semantics matter.Late-arriving data
"Latest row per entity" logic (dedup, SCD/snapshot patterns) can silently change its answer when an older event arrives late — a row you already treated as "final" is no longer actually the latest by updated_at once the late event lands. Be explicit about whether you're ordering by event time (when the thing actually happened) or load time (when your pipeline saw it) — they diverge exactly in late-arrival scenarios, and picking the wrong one is a real production bug class, not a theoretical one.
Null handling
LAG()/LEAD()returnNULLwhen there's no previous/next row — decide deliberately whether to leave thatNULL, default it with the third argument, or filter it downstream.FIRST_VALUE()/LAST_VALUE()will happily return aNULLif the first/last row in the frame has aNULLin that column.- Some engines (e.g. Snowflake, Oracle) support an
IGNORE NULLSmodifier on navigation/value functions to skip pastNULLs and find the nearest non-null value instead — check dialect support before relying on it.
Practice, rewrites & whiteboard patterns
Classic questions (write the SQL, don't just read it)
1.Find the second highest salary Medium▶
DENSE_RANK() OVER (ORDER BY salary DESC), filter drnk = 2.
2.Top 3 salaries per department Medium▶
DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC), filter drnk <= 3.
3.Latest order per customer Easy▶
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, order_id DESC), filter rn = 1.
4.Remove duplicates keeping latest row Medium▶
DELETE ... WHERE id IN (SELECT id FROM (... ROW_NUMBER() ...) WHERE rn > 1) — see the Deduplication section.
5.Users active on consecutive days Hard▶
Gaps-and-islands: login_date - ROW_NUMBER() as a constant grouping key.
6.Running total of revenue Easy▶
SUM(amount) OVER (ORDER BY order_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).
7.Month-over-month growth % Medium▶
LAG() on monthly-aggregated revenue, then (current - prev) / NULLIF(prev, 0).
8.Customers whose current order beats their previous order Medium▶
LAG(amount) wrapped in a subquery, filter amount > prev_amount.
9.Longest login streak Hard▶
Islands pattern, then MAX(streak_len) per user.
10.Sessionize a clickstream FAANG▶
LAG() gap detection → session-start flag → running SUM() of the flag.
Trick questions (explain out loud, no SQL needed)
- ROW_NUMBER vs RANK vs DENSE_RANK — what happens on a tie?
- Why doesn't
WHERE rn = 1work in the same query as the window function? - Why does
LAST_VALUE()sometimes just return the current row? - What's the actual difference between
ROWSandRANGE? - Why does a missing tie-breaker make a result nondeterministic?
- What's the difference between the window
ORDER BYand the finalORDER BY? - Why can
GROUP BYand window functions conflict in the same query? - Why does
COUNT(*) OVER()put the total row count on every single row?
Rewrite practice
- Rewrite a correlated subquery ("show each order plus that customer's max order amount") as a window function.
- Rewrite a self-join "compare row to previous row" query as
LAG(). - Rewrite a top-N-per-group query built from a join/subquery into
ROW_NUMBER(). - Rewrite a manual dedup query (
DISTINCT+ arbitrary pick) into a deterministicROW_NUMBER()dedup. - Rewrite a running total built from a self join (
SUMwhereb.date <= a.date) intoSUM() OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).
Whiteboard patterns — recognize the shape instantly
→ ROW_NUMBER/RANK/DENSE_RANK with PARTITION BY.
→ LAG() (or LEAD() for the next row).
→ ROW_NUMBER() OVER (PARTITION BY key ORDER BY recency DESC), filter rn = 1.
→ SUM() OVER (ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).
→ gap-detect with LAG(), flag, running SUM() of the flag.
→ date - ROW_NUMBER() as a constant island key, then GROUP BY it.
Self-check before you move on
You should be able to answer every one of these without looking back at the module:
- Explain
PARTITION BYvsGROUP BYin one sentence, with the row-count difference. - State the default frame when
ORDER BYis present, and explain why it breaksLAST_VALUE(). - Write a top-3-per-group query from memory, and know when to use
RANKinstead ofROW_NUMBER. - Write the three-line sessionization pattern (gap → flag → running sum) from memory.
- Explain why
WHERE rn = 1fails and name two fixes. - Explain the difference between
ROWSandRANGEwith a concrete tie example. - Explain why a missing date spine breaks a "7-day" moving average.
- Name at least three production-safety steps before running a dedup
DELETE.
If any of those feel shaky, jump back to that section using the sidebar — everything is cross-linked by the same orders schema so nothing needs to be re-learned from scratch. Once these are solid, you're ready for Module 7.