SQL Window Functions · Guide + 25 Interview Questions

SQL Window Functions and Analytic Functions: The Complete Guide

SQL window functions let you calculate values across related rows without reducing the result to one row per group. You can use them for running totals, moving averages, rankings, and comparing a row with the previous or next row, while still keeping the original rows in the result. That ability to combine row-level details with calculations across a group is what makes window functions so useful and different from regular aggregate functions. This guide covers the OVER clause, PARTITION BY, common window functions, window frames, and practical use cases, followed by twenty-five questions commonly asked in SQL interviews. All examples use the same customer and order tables as the rest of the series.

🎯 Key Takeaways
25 Interview Questions Beginner to Advanced Real Examples Updated Oct 7, 2026
Kailas Warade ✓ Verified Author
Data Engineer · Oracle Certified · SQL & PL/SQL
Kailas works with SQL, PL/SQL, Python, and PySpark to build data pipelines. He writes the SQL interview guides at Sankalan Data Tech, focused on practical, real-world answers.
🏆 Oracle Certified 💼 Data Engineer 📝 SQL & PL/SQL Guides 📍 Pune, India
📤 Share:

On This Page

What Window Functions Really Do

A regular aggregate function takes multiple rows and reduces them to a single result. For example, SUM(order_amount) can calculate the total order amount for a table or group of rows. Once the rows are grouped for that calculation, the individual rows are no longer present in that result.

A window function can perform a similar calculation while keeping every original row in the result. For example, SUM(order_amount) OVER () calculates the total order amount and shows that total alongside each individual order. The rows are not collapsed; the calculation is added to the existing result.

This makes window functions useful for problems where you need both the row-level detail and a calculation across related rows. You can show each order alongside the customer's total spending, rank employees within their department, calculate a running total, or compare a row with the previous or next row.

Window functions have their own syntax, including the OVER clause, PARTITION BY, ORDER BY, and window frames. They can take some practice to understand, but once the pattern is clear, they provide a much cleaner way to solve many problems that might otherwise require self-joins or correlated subqueries.

The Sample Data We Use in Every Example

Same customers and orders tables as the rest of the series. All window function examples in this guide work against these two tables, so you can run them alongside the earlier pages.

-- customers table
-- customer_id | customer_name    | region | credit_limit
-- C001        | Alice Corp       | North  | 50000
-- C002        | Bob Traders      | South  | 25000
-- C003        | Charlie Ltd      | North  | 75000
-- C004        | Diana Retail     | West   | 40000
-- C005        | Ethan Supplies   | South  | 55000
-- C006        | Fiona Mart       | West   | NULL
-- C007        | George Wholesale | North  | 80000

-- orders table
-- order_id | customer_id | order_amount | order_status
-- 1001     | C001        | 12000        | Delivered
-- 1002     | C002        | 4500         | Shipped
-- 1003     | C001        | 7500         | Delivered
-- 1004     | C003        | 22000        | Pending
-- 1005     | C002        | 3200         | Delivered
-- 1006     | C004        | 18000        | Shipped
-- 1007     | C001        | 9000         | Delivered

Two things in this data make the window function examples natural:

  • Alice Corp has three orders, Bob Traders has two — enough to demonstrate ranking and running totals within groups.
  • Three regions with different order counts — enough to demonstrate PARTITION BY region.

The OVER Clause and PARTITION BY

Every window function includes an OVER clause. It defines the window — the set of rows the function gets to see. Without it, the function does not know what to compute over, and the query fails.

OVER () — the whole result set

SELECT order_id,
       order_amount,
       SUM(order_amount) OVER () AS grand_total
FROM   orders;

Every order row appears with the same grand total next to it. On our data, that total is 76,200. The window function did not collapse anything — seven rows in, seven rows out, each with the total attached.

OVER (PARTITION BY ...) — one window per group

SELECT c.customer_name,
       o.order_id,
       o.order_amount,
       SUM(o.order_amount) OVER (PARTITION BY o.customer_id) AS customer_total
FROM   customers c
JOIN   orders    o ON c.customer_id = o.customer_id;

Each customer's orders show their own total. Alice Corp's three orders each show 28,500. Bob Traders's two orders each show 7,700. Charlie Ltd and Diana Retail each show their single order amount. Same number of rows as before, but the total is now scoped to the partition.

PARTITION BY is what makes window functions useful for grouped calculations. Instead of one grand total on every row, each group gets its own total. And unlike GROUP BY, the individual rows stay visible.

OVER (ORDER BY ...) — a growing window

SELECT order_id,
       order_amount,
       SUM(order_amount) OVER (ORDER BY order_id) AS running_total
FROM   orders;

Now the window grows as it moves through the rows. Order 1001 shows 12,000. Order 1002 shows 16,500 (12,000 + 4,500). Order 1003 shows 24,000. And so on, up to the grand total on the last row. That is a running total, and it comes entirely from adding an ORDER BY inside the OVER clause.

PARTITION BY and ORDER BY together

SELECT c.customer_name,
       o.order_id,
       o.order_amount,
       SUM(o.order_amount) OVER (
           PARTITION BY o.customer_id
           ORDER BY o.order_id
       ) AS running_customer_total
FROM   customers c
JOIN   orders    o ON c.customer_id = o.customer_id;

Alice Corp's orders show a running total within her own orders: 12,000, 19,500, 28,500. Bob Traders's orders show their own running total: 4,500, 7,700. The PARTITION BY resets the running total for each customer, and the ORDER BY decides the sequence inside each partition.

The mental model: think of a spreadsheet sorted by customer and order. PARTITION BY cuts the sheet into blocks, one per customer. ORDER BY sorts the rows inside each block. The window function then computes across the block as it moves down. Once that picture is in your head, every window function query becomes easier to reason about.

Ranking Functions — ROW_NUMBER, RANK, DENSE_RANK

Ranking functions assign numbers to rows based on an ordering. They differ in how they handle ties.

ROW_NUMBER — always unique

SELECT customer_name,
       credit_limit,
       ROW_NUMBER() OVER (ORDER BY credit_limit DESC) AS rn
FROM   customers
WHERE  credit_limit IS NOT NULL;

Every row gets a unique number, even if two rows have the same value. On our data, George Wholesale gets 1, Charlie Ltd gets 2, Ethan Supplies gets 3, Alice Corp gets 4, Diana Retail gets 5, and Bob Traders gets 6. No ties, because ROW_NUMBER breaks them arbitrarily.

RANK — leaves gaps after ties

SELECT customer_name,
       credit_limit,
       RANK() OVER (ORDER BY credit_limit DESC) AS rnk
FROM   customers
WHERE  credit_limit IS NOT NULL;

If two rows tie, they get the same rank, and the next row skips a number. On our data, no two customers share the same credit limit, so RANK happens to match ROW_NUMBER. But the behavior is different — if two rows tied at rank 3, the next row would be rank 5.

DENSE_RANK — no gaps

SELECT customer_name,
       credit_limit,
       DENSE_RANK() OVER (ORDER BY credit_limit DESC) AS dense_rnk
FROM   customers
WHERE  credit_limit IS NOT NULL;

Same tie behavior at the top — tied rows share a rank. But the next rank is the next integer, not a skip. If two rows tie for rank 3, the next row is rank 4, not rank 5.

Side by side on the same data

Name Credit limit ROW_NUMBER RANK DENSE_RANK
George Wholesale 80000 1 1 1
Charlie Ltd 75000 2 2 2
Ethan Supplies 55000 3 3 3
Alice Corp 50000 4 4 4
Diana Retail 40000 5 5 5
Bob Traders 25000 6 6 6

Because our sample data has no ties on credit_limit, all three functions produce identical results. The differences only show up when values repeat. Add a row with the same credit limit as an existing one and RANK and DENSE_RANK diverge immediately.

Ranking within partitions

SELECT c.region,
       c.customer_name,
       c.credit_limit,
       RANK() OVER (
           PARTITION BY c.region
           ORDER BY c.credit_limit DESC
       ) AS rank_in_region
FROM   customers c;

Now the ranking restarts for each region. North has three customers (George, Charlie, Alice) who get ranks 1, 2, 3. South has two (Ethan, Bob) who get 1, 2. West has two (Diana, Fiona — but Fiona has a NULL credit limit, so she may or may not appear depending on how the database sorts NULLs).

LAG and LEAD — Comparing to Neighbours

LAG looks backward and LEAD looks forward within the window. They let you compare a row with the row before or after it, without a self-join.

The basic pattern

SELECT customer_id,
       order_id,
       order_amount,
       LAG(order_amount)  OVER (ORDER BY order_id) AS previous_order,
       LEAD(order_amount) OVER (ORDER BY order_id) AS next_order
FROM   orders;

Each order row now shows the previous order's amount and the next order's amount next to its own. On our data:

  • Order 1001: previous is NULL (no row before), next is 4,500.
  • Order 1002: previous is 12,000, next is 7,500.
  • Order 1003: previous is 4,500, next is 22,000.
  • ...
  • Order 1007: previous is 18,000, next is NULL (no row after).

That NULL at the edges is expected. LAG has nothing to look at for the first row, and LEAD has nothing for the last.

Calculating the change between rows

SELECT customer_id,
       order_id,
       order_amount,
       order_amount - LAG(order_amount) OVER (
           PARTITION BY customer_id
           ORDER BY order_id
       ) AS change_from_previous
FROM   orders;

Each order shows how much it differs from the customer's previous order. Alice Corp's orders: first order 12,000 (change is NULL because there is no previous), second order 7,500 (change is -4,500), third order 9,000 (change is +1,500).

That pattern is the basis for period-over-period reporting. Month-over-month growth, week-over-week changes, year-over-year comparisons. All of them come from LAG or LEAD on the appropriate time column.

Offsetting more than one row

SELECT order_id,
       order_amount,
       LAG(order_amount, 2) OVER (ORDER BY order_id) AS two_rows_back
FROM   orders;

The second argument to LAG and LEAD is the offset. Passing 2 looks two rows back instead of one. That gives you comparisons like "this year versus two years ago", or "this week versus the same week last month".

Default values for out-of-range lookups

SELECT order_id,
       order_amount,
       LAG(order_amount, 1, 0) OVER (ORDER BY order_id) AS previous_or_zero
FROM   orders;

The third argument is the default. If LAG cannot find a previous row, it returns 0 instead of NULL. That is useful when you do not want downstream calculations to have to handle NULLs — you can arithmetic on the value directly.

Window Aggregates — SUM, AVG, COUNT with OVER

The standard aggregates — SUM, AVG, COUNT, MIN, MAX — all work as window functions when you add an OVER clause. Same functions, different result.

Aggregate vs window aggregate

-- Regular aggregate: one row back
SELECT SUM(order_amount) FROM orders;
-- Returns 76200.00

-- Window aggregate: every row back, total attached
SELECT order_id,
       order_amount,
       SUM(order_amount) OVER () AS total
FROM   orders;
-- Returns 7 rows, each with the same total

That is the whole trick. Adding OVER () changes the function from "collapse everything" to "compute and keep every row".

Running totals

SELECT order_id,
       order_date,
       order_amount,
       SUM(order_amount) OVER (ORDER BY order_date, order_id) AS running_total
FROM   orders;

As the window moves through the rows in order, the SUM grows. Each row shows the total of everything up to and including itself. This is the classic "running balance" pattern — cumulative sales, cumulative signups, cumulative anything.

Percentage of total

SELECT order_id,
       order_amount,
       SUM(order_amount) OVER () AS total,
       ROUND(
           order_amount * 100.0 / SUM(order_amount) OVER (),
           2
       ) AS pct_of_total
FROM   orders;

Each order's share of the overall total, as a percentage. Order 1004 (22,000) is about 28.87% of the 76,200 total. Order 1002 (4,500) is about 5.91%. The window function computes the grand total once, and the division happens per row.

Percentage within a group

SELECT customer_id,
       order_id,
       order_amount,
       SUM(order_amount) OVER (PARTITION BY customer_id) AS customer_total,
       ROUND(
           order_amount * 100.0 /
           SUM(order_amount) OVER (PARTITION BY customer_id),
           2
       ) AS pct_of_customer
FROM   orders;

Now each order shows what fraction of the customer's spending it represents. Alice Corp's order 1001 (12,000) is about 42.11% of her 28,500 total. Order 1003 (7,500) is about 26.32%. Order 1007 (9,000) is about 31.58%.

Moving averages

SELECT order_id,
       order_amount,
       AVG(order_amount) OVER (
           ORDER BY order_id
           ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ) AS moving_avg_3
FROM   orders;

Each row shows the average of itself and the two rows before it. Order 1001 has no previous rows, so the average is just 12,000. Order 1002 averages 12,000 and 4,500 — about 8,250. Order 1003 averages 12,000, 4,500, and 7,500 — 8,000. And so on. The ROWS BETWEEN clause is what makes it a moving average instead of a running average.

Other useful aggregates as window functions

  • COUNT(*) OVER (PARTITION BY customer_id) — number of orders per customer, shown on every order row.
  • MAX(order_amount) OVER (PARTITION BY customer_id) — the customer's largest order, on every row.
  • MIN(order_amount) OVER (PARTITION BY customer_id) — the customer's smallest order.
  • AVG(order_amount) OVER () — the overall average order amount, on every row.

Distribution Functions — NTILE, PERCENT_RANK, CUME_DIST

Three functions help you understand where a row sits in the distribution of the whole set. They are less commonly used than ranking but come up in analytics.

NTILE — equal-sized buckets

SELECT order_id,
       order_amount,
       NTILE(4) OVER (ORDER BY order_amount DESC) AS quartile
FROM   orders;

NTILE divides the sorted rows into n roughly equal groups and assigns each group a number. With seven rows and NTILE(4), the buckets are 2, 2, 2, 1 rows. Order 1004 (22,000) is in bucket 1 — the top quartile. Order 1005 (3,200) is in bucket 4 — the bottom quartile.

NTILE is useful for decile analysis (NTILE(10)), quartile analysis (NTILE(4)), or any other equal-size split. It is how you build a "top 10%" report or a "bottom quartile" analysis.

PERCENT_RANK — relative rank as a percentage

SELECT order_id,
       order_amount,
       ROUND(
           PERCENT_RANK() OVER (ORDER BY order_amount) * 100,
           2
       ) AS pct_rank
FROM   orders;

PERCENT_RANK returns a value between 0 and 1 that describes where the row sits in the ordered distribution. The lowest value gets 0, the highest gets 1. Order 1005 (3,200, the lowest) gets 0. Order 1004 (22,000, the highest) gets 1.0. The other rows fall in between.

The formula is (rank - 1) / (total_rows - 1). That is why the lowest value is 0 and the highest is 1 — a range from 0% to 100%.

CUME_DIST — cumulative distribution

SELECT order_id,
       order_amount,
       ROUND(
           CUME_DIST() OVER (ORDER BY order_amount) * 100,
           2
       ) AS cumulative_pct
FROM   orders;

CUME_DIST returns the fraction of rows that are less than or equal to the current row. Order 1005 (3,200, the lowest) has CUME_DIST 1/7 — about 14.29%, because one out of seven rows is at or below it. Order 1004 (22,000, the highest) has CUME_DIST 7/7 — 100%, because every row is at or below it.

The difference from PERCENT_RANK: PERCENT_RANK measures "how far along the ranking am I", while CUME_DIST measures "how many rows are at or below me". Both are useful in different contexts, and interviewers sometimes ask which one gives you what.

Side by side on the same data

Order Amount NTILE(4) PERCENT_RANK CUME_DIST
1005 3200 4 0.00 0.143
1002 4500 4 0.167 0.286
1003 7500 3 0.333 0.429
1007 9000 3 0.500 0.571
1001 12000 2 0.667 0.714
1006 18000 2 0.833 0.857
1004 22000 1 1.000 1.000

Window Frames — Controlling the Calculation Window

A window frame is the exact set of rows within the partition that a window function sees for the current row. By default, when you use ORDER BY, the frame is everything from the start of the partition up to the current row. You can override that to get moving calculations.

The default frame

SELECT order_id,
       order_amount,
       SUM(order_amount) OVER (ORDER BY order_id) AS running_total
FROM   orders;

That query implicitly uses the frame ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — every row from the start of the partition to the current one. That is why it produces a running total.

Making the frame explicit

SELECT order_id,
       order_amount,
       SUM(order_amount) OVER (
           ORDER BY order_id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_total
FROM   orders;

Same result, but now the frame is spelled out. Writing it explicitly is a good habit — it makes the intent obvious and prevents subtle differences between databases that default differently.

The three-part frame syntax

ROWS BETWEEN [start] AND [end]

The frame boundary can be one of:

  • UNBOUNDED PRECEDING — start of the partition.
  • N PRECEDING — N rows before the current.
  • CURRENT ROW — the current row.
  • N FOLLOWING — N rows after the current.
  • UNBOUNDED FOLLOWING — end of the partition.

Common frame patterns

-- Running total: everything up to now
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

-- Three-row moving average
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW

-- Five-row centred moving average
ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING

-- Everything after the current row
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING

-- Whole partition (same for every row)
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING

ROWS vs RANGE

Two frame modes exist: ROWS and RANGE. ROWS counts physical rows — "the two rows before this one". RANGE counts by value — "every row whose ordering column value is within two of this one". Most of the time you want ROWS. RANGE is useful when you are dealing with ties on the ordering column and you want them treated as a single unit.

-- ROWS: exactly 3 physical rows
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW

-- RANGE: every row within the value range
RANGE BETWEEN 2 PRECEDING AND CURRENT ROW

Real-World Use Cases

Window functions show up most often in these five patterns. Each one replaces a self-join or a correlated subquery that used to be the only way to solve the problem.

Top N per group

WITH ranked AS (
    SELECT c.customer_name,
           o.order_id,
           o.order_amount,
           ROW_NUMBER() OVER (
               PARTITION BY c.customer_id
               ORDER BY o.order_amount DESC
           ) AS rn
    FROM   customers c
    JOIN   orders o ON c.customer_id = o.customer_id
)
SELECT customer_name, order_id, order_amount
FROM   ranked
WHERE  rn <= 2;

Every customer's two largest orders. This is the single most common use of window functions in interviews and in real work. Adding a WHERE clause on the window function directly does not work — you have to wrap it in a CTE or subquery.

Running totals for financial reporting

SELECT order_id,
       order_date,
       order_amount,
       SUM(order_amount) OVER (ORDER BY order_date, order_id) AS ytd_total
FROM   orders
WHERE  EXTRACT(YEAR FROM order_date) = 2024;

Year-to-date cumulative sales. The WHERE clause narrows to the current year, and the window function accumulates within that scope.

Moving averages for trend analysis

SELECT order_date,
       order_amount,
       AVG(order_amount) OVER (
           ORDER BY order_date
           ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
       ) AS seven_day_avg
FROM   orders;

A seven-day rolling average, useful for smoothing out day-to-day noise in metrics. Same pattern works for 30-day averages, 12-month averages, or any other window size.

Comparing a row to its predecessor

SELECT order_date,
       order_amount,
       LAG(order_amount) OVER (ORDER BY order_date) AS yesterday,
       order_amount - LAG(order_amount) OVER (ORDER BY order_date) AS day_over_day_change
FROM   orders;

Daily change in sales — how much today differs from yesterday. That is the basis for almost every trend dashboard.

Deduplication

WITH numbered AS (
    SELECT order_id,
           customer_id,
           order_amount,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id, order_amount
               ORDER BY order_id
           ) AS rn
    FROM   orders
)
DELETE FROM orders
WHERE  order_id IN (SELECT order_id FROM numbered WHERE rn > 1);

That removes duplicate orders — rows that share the same customer and amount, keeping only the earliest one. Same recipe used to identify and clean duplicates in any table.

Performance and Indexing

Window functions are not free. They usually require sorting, and the sort step is often the most expensive part of the query. Three things help.

Index the partition and order columns

CREATE INDEX idx_orders_customer_amount
ON orders (customer_id, order_amount DESC);

When the PARTITION BY and ORDER BY columns are covered by an index, the database can read rows in sorted order and skip the sort step entirely. Without the index, the database has to load every row and sort it in memory.

The column order matters. A composite index on (customer_id, order_amount DESC) supports a query that partitions by customer and orders by amount. It does not support a query that partitions by order_amount and orders by customer_id.

Filter before the window function

SELECT order_id,
       SUM(order_amount) OVER (ORDER BY order_id) AS running_total
FROM   orders
WHERE  order_status = 'Delivered';

The WHERE clause reduces the row count before the window function runs. On a table with millions of rows, filtering to a tenth of them before the sort makes the sort ten times faster.

Reduce the number of window functions

Every window function on the same window shares the same sort step. But different windows — different PARTITION BY or ORDER BY — require different sorts. If a query has three window functions with three different windows, the database may sort the data three times.

When you can, reuse the same window definition across multiple functions. A single sort feeding three window functions is much cheaper than three separate sorts.

How to check: run EXPLAIN in front of the query. Window functions show up as a separate "WindowAgg" or "Window" node in the plan. If that node is operating on far more rows than you expected, the filter above it is not being pushed down. Adding a WHERE clause on the same filter or moving it earlier usually fixes the issue.

When window functions are the wrong tool

Sometimes the same result can be achieved with a subquery or a join more cheaply. "What is the maximum order amount for each customer?" can be answered with a simple GROUP BY and a join, without a window function. "Does this row appear more than once?" can be answered with GROUP BY ... HAVING COUNT(*) > 1.

Window functions are the right tool when the answer needs to be computed per row while every row is preserved. When you are going to collapse the rows anyway, an aggregate is usually faster.

25 Interview Questions and Answers

These are the window function questions that come up most often. Every answer stands on its own — read them in any order, or work through them in sequence to build up the full picture.

🔍

No matching questions found

Try a keyword like ROW_NUMBER, PARTITION, or LAG.

Window functions compute across a set of rows related to the current row, without collapsing the result. Regular aggregates reduce many rows into one; window functions keep every row visible and add the computed value alongside.

-- Regular aggregate: one row back
SELECT SUM(order_amount) FROM orders;
-- Returns 76200

-- Window aggregate: every row back, total attached
SELECT order_id,
       order_amount,
       SUM(order_amount) OVER () AS total
FROM   orders;
-- Returns 7 rows, each with the same total

The single character difference — adding OVER () — changes the function from "collapse everything" to "compute and keep every row". That is the entire idea, and everything else about window functions is a refinement.

The practical difference shows up in reports. A regular aggregate gives you a single total for the whole table. A window aggregate gives you that total on every row, alongside the individual detail — which is what most reports actually need.

OVER defines the window — the set of rows the function gets to see when computing its value for the current row. Without OVER, a window function has no scope and the query fails.

-- Whole result set
SUM(order_amount) OVER ()

-- One window per customer
SUM(order_amount) OVER (PARTITION BY customer_id)

-- Running total within each customer's orders
SUM(order_amount) OVER (
    PARTITION BY customer_id
    ORDER BY order_id
)

The three forms above show the three common cases. No PARTITION BY means one window across the whole result. Adding PARTITION BY splits into one window per group. Adding ORDER BY makes the window grow as it moves through the sorted rows.

You can also define a frame inside OVER to control exactly which rows the current row can see — for example, "the two rows before this one and the current row" for a moving average. That is where window functions start getting precise.

ROW_NUMBER assigns a unique sequential number to each row in the window, starting at 1. Even identical rows get different numbers, because ROW_NUMBER does not care about ties.

SELECT order_id,
       order_amount,
       ROW_NUMBER() OVER (ORDER BY order_amount DESC) AS rn
FROM   orders;

On our data, order 1004 (22,000) gets row number 1, order 1006 (18,000) gets 2, order 1001 (12,000) gets 3, and so on down to order 1005 (3,200) at 7.

The classic use is top N per group. Wrap the query in a CTE and filter on the row number:

WITH ranked AS (
    SELECT c.customer_name,
           o.order_id,
           o.order_amount,
           ROW_NUMBER() OVER (
               PARTITION BY c.customer_id
               ORDER BY o.order_amount DESC
           ) AS rn
    FROM   customers c
    JOIN   orders o ON c.customer_id = o.customer_id
)
SELECT customer_name, order_id, order_amount
FROM   ranked
WHERE  rn = 1;

That returns the largest order for each customer. Change rn = 1 to rn <= 3 and you get the top three per customer. This pattern shows up in interviews constantly because it requires both window function syntax and the CTE or subquery wrapper.

Both RANK and DENSE_RANK rank rows by an ordering. The difference is what happens after a tie.

SELECT order_id,
       order_amount,
       RANK()       OVER (ORDER BY order_amount DESC) AS rnk,
       DENSE_RANK() OVER (ORDER BY order_amount DESC) AS dense_rnk
FROM   orders;

On our data, no two orders share an amount, so both functions produce the same numbers. To see the difference, add a row with amount 12,000 — duplicating order 1001.

Order Amount RANK DENSE_RANK
1004 22000 1 1
1006 18000 2 2
1001 12000 3 3
1008 12000 3 3
1007 9000 5 4

Two rows tie at rank 3. With RANK, the next row gets rank 5 — the gap reflects that two positions were used. With DENSE_RANK, the next row gets rank 4 — the ranks are consecutive with no gaps.

The choice depends on what the number means. For Olympic-style ranking, RANK matches expectation — gold, silver, bronze with no skipped places. For "how many distinct tiers exist", DENSE_RANK matches expectation — after two tied third-place finishers, the next is fourth.

LEAD and LAG give you access to a value from a subsequent or preceding row in the same window. They let you compare a row with its neighbours without a self-join.

SELECT order_id,
       order_amount,
       LAG(order_amount)  OVER (ORDER BY order_id) AS previous_order,
       LEAD(order_amount) OVER (ORDER BY order_id) AS next_order
FROM   orders;

Order 1001 (12,000) shows NULL as its previous value because there is no row before it, and 4,500 as its next value. Order 1002 (4,500) shows 12,000 as previous and 7,500 as next. The pattern continues down the list, with order 1007 seeing 18,000 as its previous and NULL as its next.

The classic use is period-over-period comparison. Monthly growth, daily change, year-over-year delta — all of them come from LAG or LEAD on the appropriate time column.

SELECT order_id,
       order_amount,
       order_amount - LAG(order_amount) OVER (ORDER BY order_id) AS change
FROM   orders;

Both functions accept two optional arguments: the offset (how many rows back or forward) and a default value for out-of-range lookups. LAG(order_amount, 2, 0) looks two rows back and returns 0 instead of NULL when there is no such row.

NTILE divides the rows into equal-sized buckets and assigns a bucket number. PERCENT_RANK returns the relative rank of a row as a value between 0 and 1. Both describe where a row sits in the distribution, but they express it differently.

-- NTILE: divide into quartiles
SELECT order_id,
       NTILE(4) OVER (ORDER BY order_amount DESC) AS quartile
FROM   orders;

-- PERCENT_RANK: value between 0 and 1
SELECT order_id,
       PERCENT_RANK() OVER (ORDER BY order_amount) AS pct_rank
FROM   orders;

On our data with seven orders, NTILE(4) puts them into quartiles. The first quartile (top 25%) gets bucket 1, the next gets 2, and so on. The last bucket may have fewer rows if 7 does not divide evenly by 4.

PERCENT_RANK computes (rank - 1) / (total_rows - 1). The lowest value in the sorted order gets 0, the highest gets 1.0. Everything else falls in between as a decimal.

The reason to use NTILE is when you want discrete buckets — "top quartile", "middle 50%", "bottom quartile". The reason to use PERCENT_RANK is when you want a continuous position — "this row is at the 78th percentile". They answer slightly different questions.

Both SUM and AVG work as window functions when you add an OVER clause. The behaviour — whether they produce a running total, a moving average, or a per-group value — depends on what the OVER clause contains.

SELECT order_id,
       order_amount,
       SUM(order_amount) OVER (ORDER BY order_id) AS running_total,
       AVG(order_amount) OVER (
           ORDER BY order_id
           ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
       ) AS moving_avg_3
FROM   orders;

The first window function adds an ORDER BY with no frame, so the default frame applies — everything up to the current row. That produces a running total: 12,000, 16,500, 24,000, 46,000, 49,200, 67,200, 76,200.

The second window function has an explicit frame — two rows before plus the current row. That produces a moving average of three consecutive orders. Order 1001 is 12,000 (only itself). Order 1002 is 8,250 (average of 12,000 and 4,500). Order 1003 is 8,000 (average of 12,000, 4,500, and 7,500). And so on.

The pattern generalises. Change the frame to ROWS BETWEEN 6 PRECEDING AND CURRENT ROW and you get a seven-row moving average. Add PARTITION BY customer_id and each customer gets their own running total and moving average, computed independently.

A window frame is the specific set of rows within the partition that the window function sees for the current row. You define it with a ROWS or RANGE clause inside the OVER clause.

SELECT order_id,
       order_amount,
       SUM(order_amount) OVER (
           ORDER BY order_id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_total
FROM   orders;

That frame says: from the start of the partition all the way to the current row. The result is a running total, because the frame grows as the window moves.

The boundaries you can use:

  • UNBOUNDED PRECEDING — start of the partition
  • N PRECEDING — N rows before the current row
  • CURRENT ROW — the current row itself
  • N FOLLOWING — N rows after the current row
  • UNBOUNDED FOLLOWING — end of the partition

The default frame, when ORDER BY is present, is ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. That is why a simple SUM(x) OVER (ORDER BY y) gives a running total.

ROWS counts physical rows — "the two rows before". RANGE counts by value — "all rows whose ordering value is within two of mine". ROWS is what you want 95% of the time. RANGE is useful when the ordering column has ties and you want them treated as a single unit.

Five patterns show up again and again. Each replaces a self-join or correlated subquery that used to be the only way to solve the problem.

Ranking within groups. Top three salespeople per region, highest-paid employee per department, most-ordered product per category. Wrap the query in a CTE and filter on the row number.

WITH ranked AS (
    SELECT region, salesperson_name, sales_amount,
           ROW_NUMBER() OVER (
               PARTITION BY region ORDER BY sales_amount DESC
           ) AS rn
    FROM   sales
)
SELECT region, salesperson_name, sales_amount
FROM   ranked
WHERE  rn <= 3;

Running totals. Year-to-date revenue, cumulative signups, rolling inventory. Add an ORDER BY inside OVER and the window grows as the query moves through the data.

Moving averages. Seven-day or 30-day rolling metrics for smoothing out daily noise. Use a ROWS BETWEEN frame to define the window size.

Period-over-period comparisons. Month-over-month growth, day-over-day change, year-over-year delta. LAG on the time column gets you the previous period's value, and the difference is your growth.

SELECT month,
       revenue,
       LAG(revenue) OVER (ORDER BY month) AS prev_month,
       revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change
FROM   monthly_revenue;

Deduplication. Find and remove duplicate rows while keeping one per key. ROW_NUMBER partitioned by the key columns, keeping the row with rn = 1.

Window functions require sorting, and sorting is usually the expensive part. Four things help.

Index the partition and order columns. When the database can read rows in sorted order, it skips the sort step entirely.

CREATE INDEX idx_orders_customer_amount
ON orders (customer_id, order_amount DESC);

Filter before the window function runs. A tight WHERE clause reduces the row count that has to be sorted. On large tables, this is often a bigger win than indexing.

SELECT order_id,
       SUM(order_amount) OVER (ORDER BY order_id) AS running_total
FROM   orders
WHERE  order_status = 'Delivered';

Reuse the same window definition. Multiple window functions with the same PARTITION BY and ORDER BY share a single sort. Different windows require different sorts.

Check the execution plan. Run EXPLAIN and look for the window function node. If it is processing more rows than expected, the filter above it is not being pushed down. Moving the filter into the subquery or the outer query usually fixes it.

One more thing: window functions on very large tables can spill to disk if the sort does not fit in memory. Increasing the database's sort memory setting can help, but reducing the row count is usually the better fix.

No. Window functions are evaluated after WHERE, so the WHERE clause cannot see their results. Trying to filter on them directly produces an error or returns the wrong rows.

-- This fails
SELECT order_id,
       ROW_NUMBER() OVER (ORDER BY order_amount) AS rn
FROM   orders
WHERE  rn = 1;
-- ERROR: column "rn" does not exist

The fix is to wrap the query in a subquery or CTE and filter the outer query:

WITH ranked AS (
    SELECT order_id,
           order_amount,
           ROW_NUMBER() OVER (ORDER BY order_amount DESC) AS rn
    FROM   orders
)
SELECT order_id, order_amount
FROM   ranked
WHERE  rn = 1;

That is the standard pattern for top N per group and every other filter on a window function's result. It works on every database.

Some databases — Snowflake, BigQuery, Teradata — support a QUALIFY clause that filters on window function results without wrapping. Same result, shorter query:

SELECT order_id,
       ROW_NUMBER() OVER (ORDER BY order_amount DESC) AS rn
FROM   orders
QUALIFY rn = 1;

If you are on one of those databases, QUALIFY is cleaner. If not, the CTE wrapper is what you reach for.

Usually not directly. Most databases refuse to combine DISTINCT with window functions in the same SELECT, because the semantics conflict — DISTINCT wants to collapse rows, and window functions want to preserve them.

-- This fails on most databases
SELECT DISTINCT customer_id,
       SUM(order_amount) OVER (PARTITION BY customer_id)
FROM   orders;
-- ERROR: DISTINCT and window functions cannot be used together

The workaround is to compute the window function in a subquery and apply DISTINCT in the outer query:

SELECT DISTINCT customer_id, customer_total
FROM  (
    SELECT customer_id,
           SUM(order_amount) OVER (PARTITION BY customer_id) AS customer_total
    FROM   orders
) sub;

That produces one row per customer, with each customer's total spend. If you already have the window function and only want the distinct values, this is the way to get there.

There is usually a cleaner formulation. When you find yourself combining DISTINCT with a window function, the question is often "one row per group with a group-level calculation". That is what GROUP BY is for:

SELECT customer_id, SUM(order_amount) AS customer_total
FROM   orders
GROUP BY customer_id;

Same result, no window function needed. The window function version is only necessary when the individual rows matter — say, when you want to show each order alongside the customer total, and also a distinct list of customers. In those cases, splitting the query into two parts is usually clearer than trying to combine DISTINCT with OVER.

ROWS counts physical rows. RANGE counts by value. The distinction only matters when the ordering column has ties.

-- ROWS: exactly 3 physical rows per frame
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW

-- RANGE: every row whose value is within 2 of the current
RANGE BETWEEN 2 PRECEDING AND CURRENT ROW

Consider a table ordered by score, with these values: 10, 20, 20, 20, 30, 40. For the second 20 in the list:

  • ROWS BETWEEN 2 PRECEDING AND CURRENT ROW includes exactly three rows — the two physical rows before it plus itself — which is 10, 20, 20.
  • RANGE BETWEEN 2 PRECEDING AND CURRENT ROW includes every row whose score is between 18 and 20. That excludes the 10 and includes all three 20s.

The difference is subtle but real. With ROWS, the frame is always the same size in terms of row count. With RANGE, the frame size depends on how many rows share the current value.

In practice, ROWS is what you want 95% of the time. Moving averages, running totals, and lookback windows are all about counting rows, not matching values. RANGE comes up when you want "all rows within a window of time" or "all rows tied with the current row" — both of which are less common.

Note that the default frame when ORDER BY is present is RANGE UNBOUNDED PRECEDING AND CURRENT ROW on most databases. That is why a running total with ties can behave differently than expected — the default RANGE includes all rows tied with the current one, not just the ones physically before it. Writing the frame explicitly with ROWS avoids the surprise.

Window functions treat NULL the same way regular aggregates do — as unknown values that are skipped by SUM, AVG, MIN, and MAX, and counted by COUNT(*).

SELECT order_id,
       order_amount,
       SUM(order_amount) OVER (ORDER BY order_id) AS running_total,
       COUNT(order_amount) OVER (ORDER BY order_id) AS non_null_count
FROM   orders;

If an order had a NULL order_amount, it would not contribute to the running total, and COUNT(order_amount) would not count it. But COUNT(*) would — it counts rows, not values.

Ranking functions handle NULLs differently depending on the database. Oracle and PostgreSQL sort NULLs last by default in ascending order, so a NULL column value would get the highest row number. MySQL and SQL Server sort them first. If you care about where NULLs land in a ranking, be explicit with NULLS FIRST or NULLS LAST in the ORDER BY.

SELECT customer_name,
       credit_limit,
       RANK() OVER (ORDER BY credit_limit DESC NULLS LAST) AS rnk
FROM   customers;

Fiona Mart, whose credit limit is NULL, would get the lowest rank (or be excluded, depending on the outer filter) because her NULL sorts last in descending order.

LAG and LEAD return NULL when the requested row does not exist (first or last row of the partition). If that breaks downstream arithmetic, pass a default value as the third argument:

LAG(order_amount, 1, 0) OVER (ORDER BY order_id) AS previous_or_zero

That returns 0 instead of NULL for the first row, so any subtraction or addition downstream works without special handling.

Yes, but they operate at different stages. GROUP BY runs first and collapses rows. The window function then operates over the grouped result.

SELECT customer_id,
       SUM(order_amount) AS customer_total,
       RANK() OVER (ORDER BY SUM(order_amount) DESC) AS spend_rank
FROM   orders
GROUP BY customer_id;

That query groups orders by customer, computes the total spend, then ranks the customers by that total. The window function sees the grouped result — one row per customer — not the original orders.

The order matters. Inside a window function, you cannot reference columns that were eliminated by the GROUP BY. Writing RANK() OVER (ORDER BY order_amount) in the query above would fail because order_amount is not part of the grouped result — only its aggregate is.

You cannot use a window function inside an aggregate either. Something like SUM(ROW_NUMBER() OVER (ORDER BY x)) is not valid syntax. The two features operate at different levels of the query, and combining them directly is not allowed.

The pattern that does work is what is shown above: aggregate first with GROUP BY, then rank or window over the aggregated rows. Any window function that operates on top of a GROUP BY result uses this shape.

The standard recipe uses ROW_NUMBER in a CTE, then filters the outer query on the row number.

WITH ranked AS (
    SELECT c.customer_name,
           o.order_id,
           o.order_amount,
           ROW_NUMBER() OVER (
               PARTITION BY c.customer_id
               ORDER BY o.order_amount DESC
           ) AS rn
    FROM   customers c
    JOIN   orders o ON c.customer_id = o.customer_id
)
SELECT customer_name, order_id, order_amount
FROM   ranked
WHERE  rn <= 2;

On our data, that returns each customer's top two orders. Alice Corp gets her 12,000 and 9,000 orders. Bob Traders gets 4,500 and 3,200. Charlie Ltd and Diana Retail each have only one order, so both of theirs come back. Total: six rows.

To use RANK or DENSE_RANK instead of ROW_NUMBER, change the function name and the filter. With RANK, rn <= 2 includes ties — if two orders tie for second place, both come back. With ROW_NUMBER, only one of them comes back, chosen arbitrarily.

-- ROW_NUMBER: exactly 2 rows per customer
ROW_NUMBER() OVER (PARTITION BY c.customer_id ORDER BY o.order_amount DESC) AS rn
WHERE rn <= 2

-- RANK: up to 2 ranks, but ties can exceed 2 rows
RANK() OVER (PARTITION BY c.customer_id ORDER BY o.order_amount DESC) AS rnk
WHERE rnk <= 2

The choice depends on what you mean by "top two". Exactly two rows per group means ROW_NUMBER. The top two positions, ties included, means RANK.

FIRST_VALUE returns the value from the first row of the window. LAST_VALUE returns the value from the last row.

SELECT order_id,
       order_amount,
       FIRST_VALUE(order_amount) OVER (
           ORDER BY order_id
       ) AS first_order,
       LAST_VALUE(order_amount) OVER (
           ORDER BY order_id
           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
       ) AS last_order
FROM   orders;

FIRST_VALUE gives every row the same value — 12,000, the first order's amount. That is often useful for "show each row next to the starting value" comparisons.

LAST_VALUE is trickier. The default frame (from partition start to current row) means LAST_VALUE gives the current row's own value, not the partition's last value. To get the actual last row's value, you have to specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING explicitly. That is one of the most common bugs with window functions.

On our data with the explicit frame, LAST_VALUE returns 9,000 — the last order's amount — on every row. Without the explicit frame, it would return each order's own amount, which is the same as just selecting the column directly.

Both functions accept optional PARTITION BY and can be combined with frames. A useful pattern is FIRST_VALUE(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) — every order shows the date of the customer's first order.

For the same result, GROUP BY is usually faster. It collapses rows early, which reduces the amount of data the rest of the query has to process. Window functions keep every row, so they often sort more data.

-- GROUP BY: collapses to one row per customer
SELECT customer_id, SUM(order_amount)
FROM   orders
GROUP BY customer_id;

-- Window function: keeps every order row
SELECT customer_id,
       order_id,
       SUM(order_amount) OVER (PARTITION BY customer_id)
FROM   orders;

The first query returns four rows (one per customer with orders). The second returns seven (one per order). The GROUP BY version sorts four rows; the window version sorts seven.

The performance gap widens with scale. On a table with millions of rows grouped into a few thousand groups, GROUP BY collapses early and sorts a few thousand rows. A window function on the same data sorts millions of rows to produce the same information alongside each detail row.

That said, they answer different questions. If you only need the summary, use GROUP BY. If you need the summary attached to every detail row, you need a window function. The performance cost is the price of keeping the details.

When both are options — say, "show customers with total spend over 20,000" — comparing the two plans is worth doing. Sometimes the window function version is faster because it avoids a self-join. Sometimes the GROUP BY version is faster because it collapses early. It depends on the query, the data, and the indexes.

No, not directly. Window functions cannot nest inside each other, and one window function cannot reference the result of another in the same SELECT list.

-- This fails
SELECT order_id,
       SUM(order_amount) OVER (ORDER BY order_id) AS running_total,
       RANK() OVER (ORDER BY SUM(order_amount) OVER (ORDER BY order_id)) AS rnk
FROM   orders;
-- ERROR: window functions are not allowed here

The workaround is a CTE or subquery — compute the first window function in an inner query, then compute the second in an outer query that can see the result.

WITH totals AS (
    SELECT order_id,
           order_amount,
           SUM(order_amount) OVER (ORDER BY order_id) AS running_total
    FROM   orders
)
SELECT order_id,
       running_total,
       RANK() OVER (ORDER BY running_total DESC) AS running_rank
FROM   totals;

That computes the running total in the inner query, then ranks by running total in the outer query. Two stages, two different window functions.

The same pattern applies when you want to filter on a window function result. You cannot WHERE running_total > 50000 in the same query, but you can in a wrapping query.

Some databases allow LATERAL joins or specific syntax extensions that make nesting possible, but the general rule is that window functions are computed at one level and their results are only available to wrapping queries. Two-level CTEs handle almost every case.

Almost every modern relational database supports them. The coverage is close to universal now, but there are a few version-specific notes worth knowing.

  • Oracle — has supported window functions since version 8i (1999). The syntax is essentially the standard one.
  • SQL Server — supported since SQL Server 2005. The syntax is standard, and SQL Server also supports a few platform-specific extensions.
  • PostgreSQL — supported since version 8.4 (2009). Follows the standard.
  • MySQL — added support in version 8.0 (2018). If you are on MySQL 5.7 or earlier, they are not available.
  • SQLite — added support in version 3.25 (2018).
  • MariaDB — added support in version 10.2 (2017).
  • Snowflake, BigQuery, Redshift, Databricks — all cloud warehouses support window functions natively.

If you are on MySQL 5.7 or older, or an older version of MariaDB, you need to simulate window functions with correlated subqueries or self-joins. That is noticeably more work and often slower — one of the reasons upgrading to MySQL 8.0 is worth it for analytics workloads.

The specific functions available vary slightly. ROW_NUMBER, RANK, DENSE_RANK, SUM, AVG, COUNT, MIN, MAX, LAG, LEAD, and FIRST_VALUE are supported almost everywhere. Less common functions like PERCENTILE_CONT or PERCENTILE_DISC have narrower support — check your specific database before relying on them.

Both can compute per-row values based on other rows. They differ in how they express the calculation and how the database executes them.

A correlated subquery runs once per outer row. For a table with a million rows, that means a million subquery executions. A window function computes across all rows at once, in a single pass.

-- Correlated subquery: runs once per customer
SELECT c.customer_name,
       (SELECT SUM(o.order_amount)
        FROM   orders o
        WHERE  o.customer_id = c.customer_id) AS total_spend
FROM   customers c;

-- Window function: runs once for the whole query
SELECT c.customer_name,
       SUM(o.order_amount) OVER (PARTITION BY c.customer_id) AS total_spend
FROM   customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id;

Same output, different execution strategy. The window function version is usually much faster on large tables because the database only has to sort once.

There is a subtle difference in the output when a customer has no orders. The correlated subquery returns NULL for that customer. The window function with a LEFT JOIN returns the customer row once for each order (or one row with NULL order amount if there are no orders). If you want one row per customer, use a subquery or a GROUP BY. If you want one row per order with the customer total beside it, use the window function.

The general guidance: use window functions when you want to keep every detail row. Use correlated subqueries or aggregates when you are collapsing rows anyway. Window functions are almost always faster for the same output on modern databases.

ROW_NUMBER assigns unique numbers even when values tie. That means the tie must be broken somehow — the database picks an arbitrary order, or you give it a tie-breaker column.

-- Arbitrary tie-breaking (risky)
ROW_NUMBER() OVER (ORDER BY order_amount DESC)

-- Explicit tie-breaking (predictable)
ROW_NUMBER() OVER (ORDER BY order_amount DESC, order_id ASC)

The first version produces different results on different runs if two orders have the same amount. The database is free to pick whichever order it reaches first. That is fine for exploratory queries but a problem for reports that need to be reproducible.

Adding a unique tie-breaker column — usually the primary key — makes the ordering deterministic. On our data, two orders with the same amount would be ordered by order_id ascending, so the one with the lower id always gets the lower row number.

If you do not want to break ties arbitrarily — you want tied rows to share a rank — use RANK or DENSE_RANK instead. RANK gives the same rank to tied rows and leaves gaps in the next rank. DENSE_RANK gives the same rank and does not leave gaps.

The choice between the three functions reflects what "rank" means for the question. Exactly one row per position means ROW_NUMBER. Positions with ties mean RANK or DENSE_RANK, depending on whether subsequent positions skip numbers.

Divide the row's value by the sum of the whole partition, and multiply by 100. The window function provides the denominator.

SELECT order_id,
       order_amount,
       ROUND(
           order_amount * 100.0 / SUM(order_amount) OVER (),
           2
       ) AS pct_of_total
FROM   orders;

That computes each order's share of the 76,200 grand total. Order 1004 (22,000) is about 28.87%. Order 1002 (4,500) is about 5.91%. Every row shows its own share of the whole.

For percent within a group, add PARTITION BY:

SELECT customer_id,
       order_id,
       order_amount,
       ROUND(
           order_amount * 100.0 /
           SUM(order_amount) OVER (PARTITION BY customer_id),
           2
       ) AS pct_of_customer
FROM   orders;

Alice Corp's orders each show their share of her 28,500 total. Order 1001 (12,000) is 42.11%. Order 1003 (7,500) is 26.32%. Order 1007 (9,000) is 31.58%.

The * 100.0 is important — without it, integer division truncates the result. Writing 100 instead of 100.0 would give 0 for most rows because the integer division of 4,500 by 76,200 is 0. Always use a decimal multiplier.

The same pattern works for running percentage of total — replace SUM(order_amount) OVER () with SUM(order_amount) OVER (ORDER BY order_id). Each row then shows what percent of the grand total has been accumulated up to that row.

A self-join joins a table to itself and combines rows based on a matching condition. A window function computes across related rows without any join at all. They can sometimes produce the same result, but the window function is almost always cleaner and faster.

-- Self-join: compare each order to the previous one
SELECT o1.order_id,
       o1.order_amount,
       o2.order_amount AS previous_order
FROM   orders o1
LEFT JOIN orders o2 ON o1.order_id = o2.order_id + 1;

-- Window function: same result, no join
SELECT order_id,
       order_amount,
       LAG(order_amount) OVER (ORDER BY order_id) AS previous_order
FROM   orders;

Both queries produce the same output. The window function is shorter, does not require the assumption that order ids are consecutive, and typically runs faster because the database only needs one sort instead of a self-join.

The self-join has one advantage: it works in databases that do not support window functions, like MySQL 5.7 or older. If you are on one of those, the self-join is the workaround.

Beyond simple comparisons, window functions do things self-joins cannot. A running total, a moving average, a rank within a group — none of those are natural to express as a self-join. Before window functions existed, people wrote them using correlated subqueries, which were much slower.

The general rule: if a window function can express what you want, use it. Reach for the self-join only when you need functionality window functions do not provide — usually when you want to combine columns from two different rows into one row in a way that does not fit the window frame model.

This is one of the most common window function interview questions, and there are two reasonable answers depending on whether ties matter.

-- If there are no ties, or you want strictly the 2nd row
WITH ranked AS (
    SELECT department_id,
           employee_name,
           salary,
           ROW_NUMBER() OVER (
               PARTITION BY department_id
               ORDER BY salary DESC
           ) AS rn
    FROM   employees
)
SELECT department_id, employee_name, salary
FROM   ranked
WHERE  rn = 2;

That gives exactly one row per department — the employee whose salary ranks second by ROW_NUMBER. If two employees tie for the highest salary, one of them gets rn = 1 and the other gets rn = 2, so the "second row" is one of the tied top earners, not the actual second-highest value. That is usually not what the interviewer wants.

-- If ties should be excluded, use DENSE_RANK
WITH ranked AS (
    SELECT department_id,
           employee_name,
           salary,
           DENSE_RANK() OVER (
               PARTITION BY department_id
               ORDER BY salary DESC
           ) AS dr
    FROM   employees
)
SELECT department_id, employee_name, salary
FROM   ranked
WHERE  dr = 2;

With DENSE_RANK, tied top salaries both get rank 1, and the next distinct salary gets rank 2. That matches the plain-English meaning of "the second-highest salary" better.

Which one you write depends on the interviewer's intent. A good answer mentions both: ROW_NUMBER if you want exactly one row per department, DENSE_RANK if you want the second distinct salary tier, RANK if you want ties to share ranks but skip the next position. Asking which they mean is a sign of thoroughness, not uncertainty.

The same pattern solves a family of related questions. Third-highest salary per department: change the filter to rn = 3 or dr = 3. Highest salary per department: rn = 1 or dr = 1. Top three salaries per department: rn <= 3 or dr <= 3. Once the recipe is in your head, the variations are small edits.

Mistakes I Keep Seeing in Interviews

These come up repeatedly. None of them are exotic, and every one of them is easy to avoid once you know it is coming.

Mistake one: trying to filter on a window function in the WHERE clause. The WHERE clause runs before window functions are computed, so the reference does not exist yet. Wrap the query in a CTE and filter the outer query.
Mistake two: forgetting that LAST_VALUE uses the default frame. With the default frame, LAST_VALUE returns the current row's value, not the partition's last row. Always specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING when you actually want the last value.
Mistake three: confusing RANK and DENSE_RANK. RANK leaves gaps after ties; DENSE_RANK does not. The distinction matters most when you filter on rank = 2 — the two functions return different rows.
Mistake four: using ROW_NUMBER when ties should share a rank. ROW_NUMBER breaks ties arbitrarily, which produces inconsistent results between runs. If ties matter, use RANK or DENSE_RANK.
Mistake five: relying on ROWS when RANGE is what you meant, or vice versa. Both are valid, but they behave differently when the ordering column has ties. Read the frame definition carefully.
Mistake six: forgetting to index the partition and order columns. Window functions require sorting, and the sort is often the expensive part. Without an index, a large window function query can sort millions of rows in memory.

Follow-Up Questions Interviewers Ask

Getting the first answer right is only half the interview. What usually decides the outcome is what happens next. Here is what to prepare for on the window function topics.

After "What is a window function?" — expect

  • "How is it different from an aggregate function?"
  • "What does the OVER clause do?"
  • "Can you use window functions without PARTITION BY?"
  • "When would you use a window function instead of a GROUP BY?"

After "What is ROW_NUMBER?" — expect

  • "How does it differ from RANK?"
  • "How would you get the top three rows per group?"
  • "What happens if two rows have the same value?"
  • "Can you filter on ROW_NUMBER directly?"

After "What is LAG?" — expect

  • "What does LAG return for the first row?"
  • "How would you calculate month-over-month growth?"
  • "Can LAG look more than one row back?"
  • "How does LAG differ from a self-join?"

After "What is a window frame?" — expect

  • "What is the difference between ROWS and RANGE?"
  • "How would you write a three-row moving average?"
  • "What is the default frame when ORDER BY is present?"
  • "Why does LAST_VALUE surprise people?"

For every question above, prepare a second-level answer. The candidates who pass are not the ones who memorised the first answer — they are the ones who kept going for another thirty seconds and said something useful about why it matters.

Quick Quiz

Three questions to check whether the topics above have landed.

1. What does PARTITION BY do in an OVER clause?
2. What is the difference between RANK and DENSE_RANK?
3. Where can you filter on a window function's result?