skip to content

Window Functions

Window functions let you compute rankings, running totals, and neighbor comparisons per row without collapsing the result set. Interviewers lean on them heavily because they separate SQL users who can only GROUP BY from those who can solve real analytics problems like top-N per group in one query.

part ofSQLoverview, primer and where to startread it →
on this pageshow

questions

page 1 of 2

Which rows survive filtering on ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) = 1?

level: juniorimportance: must knowfreq 70%

answer

  1. one number per row, restarting each group
  2. the partition defines what counts as duplicate
  3. the window ORDER BY picks the survivor
  4. 1 means first row of its email group

basics

~20 s

Exactly one row per distinct email value: the one with the newest created_at. ROW_NUMBER numbers rows 1, 2, 3 inside each email partition in the given order, so number 1 is that partition's first row.

solid answer

~50 s

`PARTITION BY email` splits the table into one group per email value, and `ORDER BY created_at DESC` numbers the rows inside each group starting at 1, newest first. Keeping `rn = 1` therefore returns one row per email — the most recent one — and drops the rest. Because a window function is computed after `WHERE`, the numbering has to be produced in a CTE or derived table and filtered in the outer query: ```sql WITH ranked AS ( SELECT c.*, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) AS rn FROM customers c ) SELECT * FROM ranked WHERE rn = 1; ``` The whole row survives, not just the key columns, which is exactly what `SELECT DISTINCT email` cannot give you. Emails that appear once still get `rn = 1`, so they are kept too.

code

sql · 10 lines
sql
WITH ranked AS (
    SELECT customer_id,
           email,
           created_at,
           ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) AS rn
    FROM customers
)
SELECT customer_id, email, created_at
FROM ranked
WHERE rn = 1;

go deeper

for a junior

Be able to write the two-step shape from memory: number the rows in a CTE, filter rn = 1 outside. Know that PARTITION BY says what a duplicate is and ORDER BY says which copy you keep.

for a middle

Explain why the numbering must live in a CTE — window functions are computed after WHERE — and why ROW_NUMBER rather than RANK guarantees exactly one survivor per key.

for a senior

Show judgment about the survivor: a total ordering so the choice is repeatable, and an ORDER BY expression that encodes the real business rule, such as preferring rows with non-null contact details.

for a principal

Frame recurring deduplication as a symptom: if the same key keeps duplicating, the fix is a uniqueness constraint and an idempotent write path, with the ROW_NUMBER cleanup as the one-off that precedes it.

## The task this pattern solves A table has accumulated more than one row per business key — several `customers` rows for the same `email`, several imported events for the same `external_id`. You want **one row per key**, and you want to choose *which* one survives (usually the newest, sometimes the oldest or the most complete). `SELECT DISTINCT` cannot do this: it only removes rows that are identical in every selected column, and duplicate records normally differ in `id`, `created_at` and other columns. `GROUP BY email` cannot do it either, because grouping collapses rows and you then have no legal way to project the other columns of one specific row. Numbering rows inside each duplicate group solves both problems at once. ## What ROW_NUMBER does here `ROW_NUMBER()` is a window function: it is evaluated over a *window* of rows and returns a value for every input row without collapsing anything. - `PARTITION BY email` splits the input into independent groups, one per distinct `email` value. Numbering restarts at 1 in each partition. - `ORDER BY created_at DESC` defines the order *inside* a partition. The first row in that order gets 1, the next 2, and so on. - The result is dense and gapless per partition — `ROW_NUMBER` never repeats a number inside a partition and never skips one, which is precisely why it is the right function for deduplication. So `rn = 1` marks, for every email, the row that sorts first — the newest `created_at`. A key that occurs only once still produces one row with `rn = 1`, so nothing legitimate is lost. ## Why the two-step shape Window functions are evaluated after `FROM`, `WHERE`, `GROUP BY` and `HAVING`, and their results are only available to the `SELECT` list and `ORDER BY`. Writing `... WHERE ROW_NUMBER() OVER (...) = 1` is invalid, and referencing the alias `rn` in the same query's `WHERE` is invalid too. The portable shape is therefore always two levels: compute `rn` in a CTE or derived table, filter it in the enclosing query. ```sql WITH ranked AS ( SELECT customer_id, email, created_at, ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) AS rn FROM customers ) SELECT customer_id, email, created_at FROM ranked WHERE rn = 1; ``` ## Why ROW_NUMBER and not RANK or DENSE_RANK `RANK()` and `DENSE_RANK()` give **tied rows the same number**. If two rows for one email share the same `created_at`, both get rank 1 and `rn = 1` returns two rows for that email — the query no longer deduplicates. `ROW_NUMBER` breaks ties arbitrarily but always produces exactly one row numbered 1 per partition. "Arbitrarily" is the catch: if the window `ORDER BY` does not order the partition totally, which tied row wins is not determined by the query. When it matters which duplicate survives, append a unique column such as the primary key to the window `ORDER BY`. ## Changing which row survives The survivor is entirely a function of the window `ORDER BY`: - newest wins: `ORDER BY created_at DESC` - oldest wins: `ORDER BY created_at ASC` - smallest key wins: `ORDER BY customer_id` - prefer rows that actually have a phone number, newest first: `ORDER BY CASE WHEN phone IS NULL THEN 1 ELSE 0 END, created_at DESC` And the definition of "duplicate" is entirely a function of `PARTITION BY`: partition by `LOWER(email)` if case-insensitive matching is the business rule, or by several columns for a composite key. ## Common mistakes Expecting `SELECT DISTINCT` to be equivalent; putting `rn = 1` in the same query's `WHERE`; using `RANK()` and being surprised by two survivors; omitting the window `ORDER BY` entirely, which leaves the survivor completely unspecified; and assuming the pattern removes anything — it is a `SELECT`, so it only *shows* the deduplicated set until you write a `DELETE` or rebuild the table. Window functions are standard SQL since SQL:2003, so this pattern is portable across current engines; older MySQL (before 8.0) and older SQLite (before 3.25) predate window-function support and need a different approach.

  • What changes if you write RANK() instead of ROW_NUMBER() in that query?
    RANK() gives tied rows the same value, so if two rows for one email share the same created_at, both get rank 1 and the filter returns two rows for that email. The query stops deduplicating. ROW_NUMBER always assigns exactly one 1 per partition, which is why it is the function for this pattern.
  • How would you keep the oldest row per email instead of the newest?
    Flip the window ORDER BY to ascending: ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at ASC). Nothing else changes — the survivor is determined solely by the ordering inside the partition, so keeping the oldest, the smallest id, or the most complete record is just a different ORDER BY expression.
  • Why can't you filter on rn inside the same SELECT that computes it?
    Window functions are evaluated after WHERE, GROUP BY and HAVING, and their output is available only to the SELECT list and ORDER BY. A predicate on rn in the same query has nothing to read yet, so the pattern always needs a CTE or derived table and a filter in the enclosing query.

Think of stacking each customer's paperwork into its own pile, newest on top, then taking only the top sheet from every pile.

saying these in an interview costs you the question

  • Says SELECT DISTINCT does the same thing
  • Puts WHERE rn = 1 in the same SELECT that computes rn
  • Uses RANK() and still expects one row per key
  • Omits the window ORDER BY and assumes the newest survives
  • Thinks PARTITION BY removes rows rather than numbering them

context

open as a page

What does LAG(amount, 1, 0) OVER (ORDER BY month) return for the first row?

level: juniorimportance: must knowfreq 80%

basics

~20 s

It returns 0. LAG reads the value one row back in the window's ordering; the first row has no predecessor, so the third argument (the default) is substituted. Omit that argument and you get NULL.

open as a page

What do PARTITION BY and ORDER BY do inside a window function's OVER clause?

level: juniorimportance: must knowfreq 80%

basics

~20 s

PARTITION BY splits the rows into independent groups so the function restarts in each one; ORDER BY sequences the rows inside a partition so position-dependent functions and frames have a defined order. Both parts are optional.

open as a page

How do ROW_NUMBER(), RANK(), and DENSE_RANK() differ when the ORDER BY values tie?

level: juniorimportance: must knowfreq 88%

basics

~20 s

ROW_NUMBER() numbers every row 1, 2, 3 with no repeats. RANK() gives tied rows the same number and then skips ahead, leaving a gap. DENSE_RANK() also gives ties the same number but continues with the next integer, no gap.

open as a page

How do you return each customer's most recent order row, with all of its columns?

level: juniorimportance: must knowfreq 70%

basics

~20 s

Rank each customer's orders in a CTE with ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC), then select rows WHERE rn = 1 in an outer query. That keeps the whole row, not just the maximum date.

open as a page

How does SUM(sales) OVER (PARTITION BY region) differ from SUM(sales) with GROUP BY region?

level: juniorimportance: must knowfreq 72%

basics

~20 s

GROUP BY collapses each region's rows into one output row, so detail columns are gone. The window version keeps every input row and attaches that row's region total to it, so the row count is unchanged.

open as a page

How do you delete duplicate rows in place using a CTE over ROW_NUMBER()?

level: middleimportance: must knowfreq 55%

basics

~20 s

Rank the rows in a CTE with ROW_NUMBER() OVER (PARTITION BY the duplicate key ORDER BY a deterministic tiebreaker), then delete every row whose number is greater than 1 — targeting them by primary key.

open as a page

What is the default window frame when OVER includes ORDER BY, and when it omits it?

level: middleimportance: must knowfreq 70%

basics

~20 s

With ORDER BY and no frame clause, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which also pulls in every row tied with the current one. With no ORDER BY, the frame is the whole partition.

open as a page

What is the difference between ROWS and RANGE in a window frame clause?

level: middleimportance: must knowfreq 65%

basics

~20 s

ROWS counts physical rows around the current row, so every row gets its own frame. RANGE counts ORDER BY values, so all rows tied on the ordering key share one frame and produce the same result.

open as a page

Why does value minus ROW_NUMBER() give every run of consecutive values a constant key?

level: middleimportance: must knowfreq 55%

basics

~20 s

Within a run, the value and the row number both increase by exactly one per row, so their difference is constant; at a gap the value jumps ahead while the row number does not, so the difference changes. Grouping by that difference collapses each run.

open as a page

Given WINDOW w AS (PARTITION BY dept ORDER BY salary), what may OVER (w ...) add or override?

level: middleimportance: must knowfreq 38%

basics

~20 s

Only a frame clause. Because w already supplies PARTITION BY and ORDER BY, a referencing window may add ROWS, RANGE or GROUPS bounds and nothing else; restating PARTITION BY or supplying a different ORDER BY is an error.

open as a page

Why does LAST_VALUE(price) OVER (ORDER BY ts) return the current row's price?

level: middleimportance: must knowfreq 70%

basics

~20 s

Because an OVER clause that has ORDER BY but no explicit frame defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, so the window ends at the current row. Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

open as a page

Why can't a window function appear in WHERE, and how do you filter on one?

level: middleimportance: must knowfreq 75%

basics

~20 s

Window functions are computed after WHERE, GROUP BY and HAVING have run, so their values do not exist yet at filter time; the standard allows them only in the SELECT list and the query's ORDER BY. To filter on one, compute it in a subquery or CTE and filter in the outer query.

open as a page

Why can ROW_NUMBER() give the same rows different numbers on repeated runs?

level: middleimportance: must knowfreq 55%

basics

~20 s

ROW_NUMBER() numbers rows in the window ORDER BY sequence, but when that ordering is not unique the order among tied rows is left undefined, so re-runs may number them differently. Add a unique tiebreaker column to make the ordering total.

open as a page

In SQL, how do you return the three highest-paid employees in each department in one query?

level: middleimportance: must knowfreq 82%

basics

~20 s

Compute ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) inside a CTE or derived table, then filter that column with WHERE rn <= 3 in an outer query. A window function cannot be used in WHERE directly.

open as a page

In SQL's logical evaluation order, when are window functions computed relative to WHERE, HAVING and LIMIT?

level: middleimportance: must knowfreq 62%

basics

~20 s

Window functions run after FROM, WHERE, GROUP BY and HAVING, so they see only rows that survived filtering, and before DISTINCT, ORDER BY and LIMIT/FETCH, so row limits never shrink what a window computes over.

open as a page

What does SUM(amount) OVER (ORDER BY order_date) return on each row of a result?

level: middleimportance: must knowfreq 80%

basics

~20 s

A running (cumulative) total: on each row it sums amount from the first row of the ordered set through that row. Every input row is preserved, and the value grows as you move down the result.

open as a page

In ROWS BETWEEN 2 PRECEDING AND 1 FOLLOWING, which rows does the frame contain?

level: juniorimportance: should knowfreq 50%

basics

~20 s

At most four rows in window ORDER BY order: the two rows before the current row, the current row itself, and the one after it. Near the start or end of a partition the frame is simply truncated.

open as a page

What does the WINDOW clause do in a SELECT statement, and how does a function reference a named window?

level: juniorimportance: should knowfreq 40%

basics

~20 s

The WINDOW clause gives a window specification a name — WINDOW w AS (PARTITION BY dept ORDER BY salary) — so functions can write OVER w instead of repeating it. It is a purely syntactic factorization; the results are identical.

open as a page

How do you compute each row's percent of its group total with SUM() OVER (PARTITION BY ...)?

level: juniorimportance: should knowfreq 60%

basics

~20 s

Divide the row's value by a windowed group total: 100.0 * sales / SUM(sales) OVER (PARTITION BY category). The window sum repeats the category total on every row, so each detail row can be divided by its own group's total.

open as a page

A ROW_NUMBER dedup keeps a different survivor each run — why, and how do you fix it?

level: middleimportance: should knowfreq 50%

basics

~20 s

The window ORDER BY does not order each partition totally, so tied rows may be numbered in any order and the engine is free to pick differently between runs. Append a unique column, such as the primary key, to that ORDER BY.

open as a page

How do you compute each user's longest streak of consecutive daily logins in SQL?

level: middleimportance: should knowfreq 45%

basics

~20 s

Reduce logins to one row per user per day, number those days per user with ROW_NUMBER() ordered by date, subtract that many days from the date, group by user plus that shifted date to get each streak, then take the maximum streak length per user.

open as a page

How do you report the missing ranges in a sequence of invoice numbers using LEAD()?

level: middleimportance: should knowfreq 35%

basics

~20 s

Order the existing values and give each row the next one with LEAD(); keep rows where the next value is more than one step ahead. Each surviving row yields a gap from current + 1 to next - 1. Gaps outside the min and max need explicit bounds.

open as a page

How does FIRST_VALUE(product) OVER (ORDER BY price) differ from MIN(price) OVER ()?

level: middleimportance: should knowfreq 45%

basics

~20 s

MIN returns the smallest value of its own argument. FIRST_VALUE returns any column you name, taken from whichever row sorts first — so it answers "which product is cheapest", not just "what is the cheapest price".

open as a page

Using LAG, how do you compute each month's percent change over the previous month?

level: middleimportance: should knowfreq 62%

basics

~10 s

Subtract LAG(revenue) OVER (PARTITION BY series ORDER BY month) from revenue and divide by that same LAG value wrapped in NULLIF(..., 0). Multiply by 100.0 so the division is not integer division.

open as a page

What does an empty OVER () with no PARTITION BY or ORDER BY compute?

level: middleimportance: should knowfreq 55%

basics

~20 s

OVER () treats the entire result set surviving WHERE, GROUP BY and HAVING as one unordered partition covering every row, so SUM(amount) OVER () returns the grand total repeated on every output row, and COUNT(*) OVER () returns the total row count.

open as a page

How does NTILE(4) distribute rows when the row count is not divisible by 4?

level: middleimportance: should knowfreq 40%

basics

~20 s

NTILE(n) splits the ordered partition into n buckets as evenly as possible: with R rows, the first R mod n buckets get one extra row each. Ten rows into four buckets gives sizes 3, 3, 2, 2.

open as a page

In a top-3-per-group query, how does RANK() differ from ROW_NUMBER() when the metric ties?

level: middleimportance: should knowfreq 66%

basics

~20 s

ROW_NUMBER() never repeats a number, so rn <= 3 returns at most three rows per group. RANK() gives tied rows the same number, so rank <= 3 can return more than three rows when third place is tied.

open as a page

Why use a window function instead of joining back to a GROUP BY subquery for per-row aggregates?

level: middleimportance: should knowfreq 58%

basics

~20 s

Both put a group aggregate on every detail row, but the window version needs no subquery, no join key and no duplicated predicate: one SELECT over one row source, with the filter written once so the total always matches the rows shown.

open as a page

Is SUM(SUM(amount)) OVER () legal in a GROUP BY query, and what does it compute?

level: middleimportance: should knowfreq 44%

basics

~10 s

Yes. Window functions are evaluated after grouping, so the inner SUM aggregates each group and the outer window SUM adds those group totals up, returning the grand total repeated on every group row.

open as a page

showing 1–30 of 49