SQL Window Functions: RANK, LAG, LEAD, ROW_NUMBER (2026)
📈 SQL Cluster — 9 of 15 · Advanced

SQL Window Functions: RANK, LAG, LEAD, ROW_NUMBER (2026)

HR wants "each employee's salary next to their department's average" — one row per employee, department average included. GROUP BY can only give one row per department, collapsing every individual employee in the process; it's structurally the wrong tool for a question that needs both the individual and the group context at once. Window functions solve exactly this: calculations across related rows, without collapsing anything. This guide covers ROW_NUMBER, RANK vs DENSE_RANK, LAG and LEAD, running totals, PARTITION BY, and the top-N-per-group pattern.

by Khalid Hussain Published Jul 31, 2026 🕐 13 min read

Quick Answer: What Do Window Functions Do?

▶ Direct Answer — SQL Window Functions

A window function performs a calculation across a set of rows related to the current row — without collapsing them like GROUP BY does. Every original row stays in the output, with the calculated value (a rank, a running total, a comparison to another row) added as a new column. The OVER clause defines the "window": PARTITION BY scopes it to a group, and ORDER BY defines the row sequence for ranking, running totals, or LAG/LEAD comparisons.

0
rows collapsed by a window function, unlike GROUP BY
Every input row keeps its own output row
2
clauses inside OVER that do the real work: PARTITION BY, ORDER BY
Both optional, both change behavior significantly
1
gap-vs-no-gap difference between RANK and DENSE_RANK
The most commonly confused pair

The Report GROUP BY Couldn't Build

HR asks for a report showing "each employee's salary, next to their department's average salary" — one row per employee. Someone reaches for the familiar tool: SELECT department, AVG(salary) FROM employees GROUP BY department. That query runs fine and returns a correct average — but it's one row per department, with every individual employee's name and salary gone. GROUP BY can't produce what was actually asked for, because collapsing rows is what GROUP BY fundamentally does; there's no way to keep individual employee rows while also summarizing at the department level in the same aggregation.

This is precisely the gap window functions fill: a calculation that spans a group of related rows, attached back onto every individual row instead of replacing them. The employee keeps their own row; the department average just becomes one more column on it.

Anatomy of OVER and PARTITION BY

Every window function shares the same basic shape: a function, followed by OVER (...) defining the window it operates across.

sqlWINDOW_ANATOMY.SQL
-- fixes the HR report from the hook: every employee, with their department's average alongside
SELECT
  name,
  department,
  salary,
  AVG(salary) OVER (PARTITION BY department) AS dept_avg_salary
FROM employees;

Every employee keeps their own row. PARTITION BY department tells the window function to calculate the average independently within each department, rather than across the whole table — an engineering department employee sees the engineering average; a sales department employee sees the sales average. Remove PARTITION BY entirely and the window function calculates across the whole result set as one single window instead.

ROW_NUMBER: A Unique Sequential Number

ROW_NUMBER() assigns a strictly increasing, unique integer to each row within its window, based on the ORDER BY inside OVER — no ties, ever, even when the ordering values are identical.

sqlROW_NUMBER.SQL
SELECT
  name, department, salary,
  ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank
FROM employees;

Within each department, this numbers employees 1, 2, 3... by salary, highest first. If two employees in the same department have the exact same salary, ROW_NUMBER still assigns them different numbers — the specific tie-break order depends on the database when no further ORDER BY column is given, which is why ROW_NUMBER isn't the right choice when ties genuinely need to share a rank.

RANK vs DENSE_RANK: How Ties Are Handled

Both assign the same rank to tied rows — the difference is entirely in what happens to the rank number immediately after a tie.

sqlRANK_VS_DENSE_RANK.SQL
SELECT
  name, salary,
  RANK() OVER (ORDER BY salary DESC) AS rnk,
  DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_rnk
FROM employees;
Salary rankRANK()DENSE_RANK()
Highest11
2nd (tied)22
2nd (tied)22
Next distinct value4 — skips 33 — no gap

With two people tied for 2nd place, RANK() gives them both 2, then jumps straight to 4 for the next row — the "missing" 3 accounts for how many rows tied. DENSE_RANK() gives them both 2 as well, but continues at 3 with no gap. Which one is correct depends entirely on what the rank number needs to mean: "how many people are strictly ahead of me" (RANK) versus "how many distinct salary levels are ahead of me" (DENSE_RANK).

LAG and LEAD: Comparing to Another Row

LAG pulls a value from a previous row; LEAD pulls one from a following row — both relative to the ordering defined in the window, without needing a self-join.

sqlLAG_LEAD.SQL
-- month-over-month revenue change, no self-join required
SELECT
  month,
  revenue,
  LAG(revenue) OVER (ORDER BY month) AS prev_month_revenue,
  revenue - LAG(revenue) OVER (ORDER BY month) AS change
FROM monthly_revenue;

Before window functions, this kind of "compare to the previous row" logic typically required a self-join on a date offset — genuinely awkward SQL. LAG replaces that entirely: one line, no join. LEAD is the same idea in the opposite direction — pulling a value from a row that comes after the current one, useful for things like "days until the next order" or "next scheduled payment date."

Running Totals

Any aggregate function can be used as a window function — SUM() OVER (ORDER BY ...) with no PARTITION BY produces a running total by default.

sqlRUNNING_TOTAL.SQL
SELECT
  order_date,
  order_total,
  SUM(order_total) OVER (ORDER BY order_date) AS running_total
FROM orders
ORDER BY order_date;

Without an explicit frame clause, SUM() OVER (ORDER BY ...) defaults to summing from the first row through the current row — exactly what a running total needs. This same pattern — an aggregate function combined with ORDER BY inside OVER — also produces running averages, running counts, and running min/max, all with the same basic shape.

Top N Per Group: The Single Most Useful Pattern

Combining ROW_NUMBER with PARTITION BY, then filtering on the result, answers "top N per group" — a question that's genuinely awkward to express without window functions.

sqlTOP_N_PER_GROUP.SQL
-- the top 3 highest-paid employees in EACH department
WITH ranked AS (
  SELECT
    name, department, salary,
    ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn
  FROM employees
)
SELECT name, department, salary
FROM ranked
WHERE rn <= 3;
⚠ Why this needs a CTE

Window functions can't be referenced directly in WHERE, for the same logical-order reason covered in the GROUP BY article: WHERE runs before window functions are calculated. Wrapping the window function in a CTE (or subquery), then filtering the outer query on its result, is the standard and only way to filter by a window function's output.

Window Functions vs GROUP BY, Side by Side

QuestionGROUP BYWindow function
Output rowsOne per groupEvery original row kept
Can show individual + group value together?NoYes
Can rank or compare rows to neighbors?NoYes — RANK, LAG, LEAD
Filtered withHAVINGOuter WHERE on a CTE/subquery

The two aren't competing tools — they answer structurally different questions. GROUP BY is correct when you genuinely want fewer rows, summarized. Window functions are correct when you need every original row preserved, with additional group-aware or row-relative context attached.

Mistakes That Give Away a Beginner

  • Using GROUP BY when the individual rows are still needed — the exact trap from this article's opening. If any original row detail must survive, GROUP BY is the wrong tool.
  • Trying to filter a window function directly in WHERE. It has to be wrapped in a CTE or subquery first, then filtered in the outer query.
  • Confusing RANK and DENSE_RANK and getting an unexpected gap (or lack of one) after a tie.
  • Forgetting PARTITION BY when a calculation should reset per group, causing it to run across the entire result set instead.
  • Using ROW_NUMBER when ties should share a rank. ROW_NUMBER never ties — reach for RANK or DENSE_RANK when tied values genuinely need the same rank.

Practice: Write These Three Queries Yourself

Using an orders (order_id, customer_id, order_date, order_total) table, try writing these:

1

Rank within a group

Return every order with its rank by order_total, calculated separately within each customer_id.

2

Compare to the previous row

Return each order's total alongside that same customer's previous order total, using LAG.

3

Top N per group

Return only each customer's single largest order, using ROW_NUMBER inside a CTE.

Frequently Asked Questions

What is the difference between a window function and GROUP BY in SQL?

GROUP BY collapses multiple rows into one summary row per group, losing the individual row detail. A window function calculates a value across a set of related rows but keeps every original row in the output, adding the calculated result as an extra column instead of collapsing anything.

What is the difference between RANK and DENSE_RANK in SQL?

Both assign a rank based on ORDER BY within a window, and both give tied rows the same rank. RANK then skips the next rank number by the number of ties, leaving a gap, while DENSE_RANK continues with the very next consecutive number, leaving no gap.

What do LAG and LEAD do in SQL?

LAG returns a value from a previous row relative to the current row, and LEAD returns a value from a following row, both based on the ordering defined in the window. They're commonly used to compare a row to the one before or after it, such as calculating month-over-month change.

What does PARTITION BY do in a SQL window function?

PARTITION BY divides the rows into independent groups before the window function is applied, resetting the calculation for each group. Unlike GROUP BY, it doesn't collapse the rows in each group into one row — every row remains in the output, with the window calculation scoped to just its own partition.

How do you calculate a running total in SQL?

Use SUM as a window function with an ORDER BY inside the OVER clause, such as SUM(amount) OVER (ORDER BY order_date). Without an explicit frame, the default behavior sums from the first row through the current row, producing a running total that grows with each subsequent row.

How do you find the top N rows per group in SQL?

Use ROW_NUMBER with PARTITION BY the grouping column and ORDER BY the ranking criterion inside a CTE or subquery, then filter the outer query WHERE row_num <= N. This returns exactly N rows per group based on whatever ordering was specified.

Conclusion: Keep the Rows, Add the Context

Window functions solve a specific gap GROUP BY structurally can't: showing individual rows alongside group-level or row-relative context, without collapsing anything. ROW_NUMBER for a strict sequence, RANK or DENSE_RANK depending on how ties should be handled, LAG and LEAD for comparing a row to its neighbors, and any aggregate function with ORDER BY for a running calculation. Remember that filtering a window function's result always requires wrapping it in a CTE first — it can't be referenced directly in WHERE.

Practice the three queries above against real data, then move to the next article in this cluster, which covers query optimization — making queries like these run fast on tables with millions of rows.

⚡ Continue the Advanced Tier

Next Up: SQL Query Optimization

Ten concrete techniques for making slow queries fast — indexes, execution plans, and more.

▶  Read the Query Optimization Guide