Module 6 — Window Functions

From "what does OVER() even mean" to running totals, gap-and-island detection, sessionization, and retention analysis — the single most tested topic in FAANG SQL interviews.
Foundations

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.

Analogy Think of a school report card. 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 BY is 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:

  1. Figures out which other rows belong to the same "window" (the PARTITION BY group, or the whole result set if there's no partition).
  2. Within that window, sorts by ORDER BY if one is given.
  3. Applies the frame clause (which subset of the partition — e.g. "from the start up to this row") if one is given.
  4. Computes the function (rank, sum, lag, etc.) over exactly that frame, and attaches the result to the current row — without deleting any other rows.
Common mistake Beginners assume a window function runs once per query, like a plain aggregate. It actually conceptually re-evaluates per row (real engines optimize this with a single sort + streaming pass per window spec, not literally N passes — but mentally model it as "recomputed per row" until you internalize the frame concept).
Foundations

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)
);
orders (sample rows)
order_idcustomer_idorder_dateamountstatus
11012024-01-03250.00completed
21012024-01-1090.00completed
31012024-02-01400.00completed
41022024-01-05120.00completed
51022024-01-0675.00refunded
61032024-01-20600.00completed

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.

Level 1 · Beginner

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;
Performance noteA missing 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.

TrapPeople assume rows come back "in rank order" just because they used 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.

Level 1 · Beginner

PARTITION BY vs GROUP BY

GROUP BY

Collapses many rows into one row per group. The detail rows are gone — you only get the aggregate.

PARTITION BY

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;
AnalogyPARTITION 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.
Level 2 · Intermediate

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

amounts: 400, 400, 250, 90 → ranked DESC
amountROW_NUMBERRANKDENSE_RANK
400111
400211
250332
90443
  • 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 n roughly-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

TrapIf two rows tie on the 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;
Level 2 · Intermediate

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;
The classic LAST_VALUE trap

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.

Level 3 · Advanced

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

Memorize thisWhen a window has an 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

ROWS

Counts physical rows. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW always means exactly 7 rows, regardless of whether their ORDER BY values are unique.

RANGE

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.

Why explicit frames are safer in productionDifferent engines have subtly different defaults and different levels of 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.
Level 3 · Advanced

Filtering window results: subquery, CTE, QUALIFY

Why this failsWindow functions are evaluated after 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.

Level 3 · Advanced

Running totals & aggregate windows

The same five aggregates you already know from GROUP BYSUM, 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;
Rule of thumbAggregate + no 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.
Level 3 · Advanced

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

The missing-date problem

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.

Level 3 · Advanced

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 trickWrapping the denominator in 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.

Data Engineering

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.

Production safety: validate before you deleteBefore running a dedup 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.
Data Engineering

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

"Top 3, ties included"

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.

"Exactly 3 rows, no more"

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.

Interview signalAsking "do you want ties included or exactly N rows?" before writing the query is itself a strong interview signal — it shows you know these aren't the same function.

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;
Data Engineering

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;
Data Engineering

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.
AnalogyThink of the session-start flags as tally marks on a wall each time someone "walks back into the room" after being away 30+ minutes. The running total of tally marks is the room's current visit number — every event between two tally marks belongs to the same visit.

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.

Data Engineering

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 userMIN(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_offset aggregation produces the classic triangular retention table (rows = cohorts, columns = months since signup).
Pattern to rememberWindow function computes a per-row attribute (first date, cohort), then a normal GROUP BY aggregates on top of it. Cohort analysis is the textbook example of chaining window functions into a regular aggregation.
Data Engineering

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

Fan-out trapJoining fact rows directly against a raw Type-2/history dimension table (one with multiple rows per entity) silently multiplies your fact rows — one match per historical version instead of one. Always collapse the dimension to "current row per entity" with ROW_NUMBER() = 1 before the join, or explicitly join on an effective-date range if you need point-in-time correctness.
Data Engineering

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.

Performance

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 BY column(s) first, then the window ORDER BY column(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

Watch for this in EXPLAIN plansWhen a partition (or the sort feeding it) is too large to fit in the memory allotted to the query, the engine spills intermediate data to disk — look for terms like "External Merge," "spill," or "disk-based sort" in the execution plan. Spills are usually the difference between a query that takes seconds and one that takes minutes.

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

Production tipIf your real question only needs daily/customer-grain numbers, aggregate to that grain first (a 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.
Performance

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_id with 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.
Practical takeawayWhen a window function feels unexpectedly slow on Spark/Snowflake/BigQuery, check the PARTITION BY key's cardinality and skew before assuming the frame or function itself is the problem.
Performance

Dialect differences

Window function support by engine
EngineNotes
PostgreSQLStrong, standards-compliant window support; named windows; FILTER clause usable alongside window aggregates (SUM(x) FILTER (WHERE cond) OVER (...)); no QUALIFY.
SQL ServerFull 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."
MySQLWindow functions only exist from MySQL 8.0 onward — earlier versions require workarounds (session variables, self joins). No QUALIFY.
BigQueryFull analytic/window support; supports QUALIFY.
SnowflakeRich window function support; supports QUALIFY; commonly used for exactly the dedup/latest-row patterns above.
OracleCalls 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.
Portability trapCode that relies on 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.
Level 4 · Hard Interview

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.

Level 4 · Hard Interview

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.

Level 5 · FAANG

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.

Level 5 · FAANG

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 SELECT before ever running the DELETE.
  • 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

RecapA moving average computed 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() return NULL when there's no previous/next row — decide deliberately whether to leave that NULL, default it with the third argument, or filter it downstream.
  • FIRST_VALUE()/LAST_VALUE() will happily return a NULL if the first/last row in the frame has a NULL in that column.
  • Some engines (e.g. Snowflake, Oracle) support an IGNORE NULLS modifier on navigation/value functions to skip past NULLs and find the nearest non-null value instead — check dialect support before relying on it.
Practice

Practice, rewrites & whiteboard patterns

25 items across 4 categories — work top to bottom, don't skip around

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 = 1 work in the same query as the window function?
  • Why does LAST_VALUE() sometimes just return the current row?
  • What's the actual difference between ROWS and RANGE?
  • Why does a missing tie-breaker make a result nondeterministic?
  • What's the difference between the window ORDER BY and the final ORDER BY?
  • Why can GROUP BY and 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 deterministic ROW_NUMBER() dedup.
  • Rewrite a running total built from a self join (SUM where b.date <= a.date) into SUM() OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).

Whiteboard patterns — recognize the shape instantly

"Rank rows within each group"

ROW_NUMBER/RANK/DENSE_RANK with PARTITION BY.

"Compare current row to previous row"

LAG() (or LEAD() for the next row).

"Keep latest row per entity"

ROW_NUMBER() OVER (PARTITION BY key ORDER BY recency DESC), filter rn = 1.

"Create a running total"

SUM() OVER (ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW).

"Break an event stream into sessions"

→ gap-detect with LAG(), flag, running SUM() of the flag.

"Find streaks of consecutive days"

date - ROW_NUMBER() as a constant island key, then GROUP BY it.

Before you say NEXT

Self-check before you move on

You should be able to answer every one of these without looking back at the module:

  • Explain PARTITION BY vs GROUP BY in one sentence, with the row-count difference.
  • State the default frame when ORDER BY is present, and explain why it breaks LAST_VALUE().
  • Write a top-3-per-group query from memory, and know when to use RANK instead of ROW_NUMBER.
  • Write the three-line sessionization pattern (gap → flag → running sum) from memory.
  • Explain why WHERE rn = 1 fails and name two fixes.
  • Explain the difference between ROWS and RANGE with 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.