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
OVER defines the window a function sees
PARTITION BY groups rows; ORDER BY sequences them
ROW_NUMBER, RANK, DENSE_RANK rank rows
LAG, LEAD look at neighbouring rows
Window frames control moving calculations
25 Interview QuestionsBeginner to AdvancedReal ExamplesUpdated Oct 7, 2026
KW
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
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.
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?