Window Functions for Interviews: ROW_NUMBER, RANK and LAG
Codevity Admin · 10/25/2026
Codevity Admin · 10/25/2026
A window function calculates a value for each row using a set of related rows (its "window") without collapsing the rows the way GROUP BY does. Window functions power the classic interview problems: ranking, "Nth highest salary", top N per group, removing duplicates, running totals, and comparing a row with the previous one.
function_name(...) OVER (
PARTITION BY column -- optional: restart the calculation for each group
ORDER BY column -- optional or required, depending on the function
frame clause -- optional: which rows around the current one to use
)GROUP BY returns one row per group. A window function keeps every row and adds a column.
SELECT name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC, id) AS row_num,
RANK() OVER (ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rnk
FROM employees
WHERE salary IS NOT NULL;| name | salary | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| Asha | 90000 | 1 | 1 | 1 |
| Ben | 75000 | 2 | 2 | 2 |
| Carla | 75000 | 3 | 2 | 2 |
| Dev | 60000 | 4 | 4 | 3 |
| Esha | 55000 | 5 | 5 | 4 |
| Gita | 50000 | 6 | 6 | 5 |
Ben and Carla tie on 75000, and that is where the three functions differ:
ROW_NUMBER never repeats. Tied rows still get different numbers, in an arbitrary order unless you add a tie-breaker such as id.RANK gives tied rows the same rank and then skips numbers. After two rows at rank 2, the next rank is 4.DENSE_RANK gives tied rows the same rank and does not skip. The next rank is 3.| You want... | Use |
|---|---|
| Unique numbers, for example to remove duplicates or pick exactly one row | ROW_NUMBER |
| Ties share a position and gaps are acceptable (sports rankings) | RANK |
| Ties share a position, no gaps (Nth highest salary) | DENSE_RANK |
You cannot filter on a window function in WHERE, because windows are calculated after WHERE. Calculate the rank in a CTE (or subquery) and filter in the outer query:
WITH ranked AS (
SELECT salary,
DENSE_RANK() OVER (ORDER BY salary DESC) AS salary_rank
FROM employees
WHERE salary IS NOT NULL
)
SELECT DISTINCT salary
FROM ranked
WHERE salary_rank = 2; -- change 2 to NThis returns 75000. DENSE_RANK is the right choice because "second highest salary" means the second highest distinct value, even when two people share the top salary. See the Nth highest salary snippet, which also shows an version.
For the specific case of the second highest, a classic alternative without window functions is .
"Highest-paid employee in each department" is the same trick with :
The numbering restarts for each department, so is the top earner of each. If tied employees should all be returned, use instead of .
Number the rows inside each group of duplicates and delete every row except the first. The full pattern is in delete duplicate rows and keep one. To only find them, see find duplicate rows.
reads a value from a previous row, and from a following row. With a table:
| sales_month | amount | change_from_previous |
|---|---|---|
| 2026-01-01 | 100 | NULL |
| 2026-02-01 | 120 | 20 |
| 2026-03-01 | 90 | -30 |
The first row has no previous row, so returns . The full form is , for example to use 0 instead of .
The frame () says which rows to add up: everything from the first row up to the current one. Always write it. If you leave it out, the default frame is , which treats rows with equal values as one group (so tied dates show the same total) and is slower in SQL Server. See the running total snippet.
OFFSET ... FETCHSELECT MAX(salary) FROM employees WHERE salary < (SELECT MAX(salary) FROM employees)PARTITION BYrn = 1RANKROW_NUMBERLAGLEADmonthly_salesLAGNULLLAG(column, offset, default)LAG(amount, 1, 0)NULLROWS BETWEEN ...RANGE ... CURRENT ROWORDER BYSELECTORDER BYWHEREGROUP BYHAVINGSUMAVGCOUNTMINMAXOVERPARTITION BYsalary DESCsalaryWITH ranked AS (
SELECT name, department_id, salary,
ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn
FROM employees
WHERE salary IS NOT NULL
AND department_id IS NOT NULL
)
SELECT name, department_id, salary
FROM ranked
WHERE rn = 1;SELECT sales_month,
amount,
amount - LAG(amount) OVER (ORDER BY sales_month) AS change_from_previous
FROM monthly_sales;SELECT order_date, total,
SUM(total) OVER (
ORDER BY order_date, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;