SQL Window Functions: ROW_NUMBER, RANK & PARTITION BY
A window function computes a value across related rows while keeping each result row. Use OVER to define the window, PARTITION BY to separate groups, and a window ORDER BY when sequence matters. Unlike GROUP BY, this lets a report show individual rows beside ranks, running totals or group averages. SQLite window-function reference.
On this page 11 sections ▾
- Numbering rows: ROW_NUMBER ▶ runs here
- A group value on every row: AVG OVER PARTITION BY ▶ runs here
- Ranking with ties: RANK and DENSE_RANK ▶ runs here
- The interview classic: top earner per department ▶ runs here
- Top N per group when there are ties ▶ runs here
- Running totals and moving averages ▶ runs here
- Comparing with the previous row: LAG and LEAD ▶ runs here
- Splitting rows into buckets: NTILE ▶ runs here
- Window functions vs GROUP BY, in one sentence each
- Frequently asked questions
- Related reading
A window function computes a value for each row from a set of related rows — its window — without collapsing them. GROUP BY answers "what is the average salary per department" by folding each department into one row. A window function answers "show me every employee, each with their department's average alongside" — all the rows survive, and each gets a value computed over its window. That one idea powers rankings, running totals, comparisons with the previous row, and the top-N-per-group questions that come up in many SQL interviews.
Every example below runs in the playground, which uses SQLite, and shows the rows it returns. The window syntax itself — OVER, PARTITION BY, the ORDER BY inside it and the frame clauses used here — is standard SQL and works the same way in PostgreSQL, MySQL 8+, SQL Server and Oracle.
Numbering rows: ROW_NUMBER ▶ runs here
SELECT ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn,
first_name, salary
FROM Employees
ORDER BY rn;
| rn | first_name | salary |
|---|---|---|
| 1 | Chen | 156000 |
| 2 | Ananya | 142000 |
| 3 | Tomas | 131000 |
| 4 | Marcus | 118000 |
| 5 | Priya | 105000 |
| 6 | Leila | 96000 |
| 7 | Isabela | 92000 |
| 8 | Grace | 89500 |
| 9 | Sofia | 79000 |
| 10 | Hiroshi | 74500 |
| 11 | Daniel | 68000 |
| 12 | Omar | 61000 |
● 12 rows · produced by running this query on the sample database
OVER is what makes a function a window function. Here the window is the whole table ordered by salary, and ROW_NUMBER hands out 1, 2, 3… down that order. Unlike ORDER BY alone, the position becomes a real value you can filter on — which is how "give me rows 11 to 20" pagination is built.
Notice the two ORDER BYs, because they do different jobs. The one inside OVER decides which row gets which number; the one at the end decides the order the rows are shown in. Leave the outer one out and the database may return the rows in any order, numbers and all.
A group value on every row: AVG OVER PARTITION BY ▶ runs here
SELECT first_name, dept_id, salary,
AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg
FROM Employees
ORDER BY dept_id, salary DESC;
| first_name | dept_id | salary | dept_avg |
|---|---|---|---|
| Ananya | 10 | 142000 | 99200 |
| Marcus | 10 | 118000 | 99200 |
| Leila | 10 | 96000 | 99200 |
| Sofia | 10 | 79000 | 99200 |
| Omar | 10 | 61000 | 99200 |
| Tomas | 20 | 131000 | 100000 |
| Priya | 20 | 105000 | 100000 |
| Grace | 20 | 89500 | 100000 |
| Hiroshi | 20 | 74500 | 100000 |
| Daniel | 30 | 68000 | 68000 |
| Chen | 50 | 156000 | 124000 |
| Isabela | 50 | 92000 | 124000 |
● 12 rows · produced by running this query on the sample database
PARTITION BY is GROUP BY's non-destructive cousin: it splits the rows into per-department windows, computes the average within each, and stamps it on every row of that department: every Engineering row (dept 10) carries 99200, every Sales row (dept 20) 100000. Twelve employees in, twelve rows out — each now carrying its own comparison point. Computing "who earns above their department's average" becomes a simple wrap of this query, no correlated subquery required.
Ranking with ties: RANK and DENSE_RANK ▶ runs here
The three numbering functions differ only in how they treat ties, and no two employees earn the same, so this example ranks products by stock level instead: three of them share the top figure of 999.
SELECT name, in_stock,
ROW_NUMBER() OVER (ORDER BY in_stock DESC, name) AS row_num,
RANK() OVER (ORDER BY in_stock DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY in_stock DESC) AS dense_rnk
FROM Products
ORDER BY in_stock DESC, name;
| name | in_stock | row_num | rnk | dense_rnk |
|---|---|---|---|---|
| Atlas Cloud Suite | 999 | 1 | 1 | 1 |
| Beacon Analytics | 999 | 2 | 1 | 1 |
| Harbor Support Plan | 999 | 3 | 1 | 1 |
| Quill Keyboard | 210 | 4 | 4 | 2 |
| Nimbus Docking Hub | 130 | 5 | 5 | 3 |
| Vertex Monitor 27" | 68 | 6 | 6 | 4 |
| Aurora Laptop 14" | 42 | 7 | 7 | 5 |
● 7 rows · produced by running this query on the sample database
RANK gives the three tied products 1 and then skips: Quill Keyboard, fourth in line, gets 4.
DENSE_RANK also gives the three 1 but does not skip, so Quill Keyboard gets 2. ROW_NUMBER never ties —
the three get 1, 2 and 3 — and which of them gets which is up to the database unless you add a
tie-breaker. name is the tie-breaker here, which is why those three are numbered
alphabetically. Interviewers often put the difference as "1, 2, 2, 4 versus 1, 2, 2, 3"; the table is the
same idea with real rows.
The interview classic: top earner per department ▶ runs here
WITH ranked AS (
SELECT first_name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn
FROM Employees
)
SELECT first_name, dept_id, salary
FROM ranked
WHERE rn = 1
ORDER BY dept_id;
| first_name | dept_id | salary |
|---|---|---|
| Ananya | 10 | 142000 |
| Tomas | 20 | 131000 |
| Daniel | 30 | 68000 |
| Chen | 50 | 156000 |
● 4 rows · produced by running this query on the sample database
"Highest paid employee in each department" — the question that separates people who know window functions
from people who fight it with correlated subqueries. Rank within each partition, keep rank 1. The
ranking has to happen in a CTE or subquery because WHERE is evaluated before window functions, so
rn does not exist yet at that point. Four rows come back: Support (dept 40) has no employees,
so it has no partition at all. Change rn = 1 to rn <= 3 and it is top three
per department.
Top N per group when there are ties ▶ runs here
ROW_NUMBER is the right tool only when you want exactly N rows per group. If two rows tie at the cut-off, it keeps one and silently drops the other. Here is "the customer with the most orders in each tier", ranked with RANK instead:
WITH ranked AS (
SELECT c.tier, c.company, COUNT(*) AS orders,
RANK() OVER (PARTITION BY c.tier ORDER BY COUNT(*) DESC) AS rnk
FROM Customers c
JOIN Orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.tier, c.company
)
SELECT tier, company, orders
FROM ranked
WHERE rnk = 1
ORDER BY tier, company;
| tier | company | orders |
|---|---|---|
| Bronze | Baltic Systems | 1 |
| Gold | Meridian Trading Co. | 3 |
| Platinum | Acme Robotics | 2 |
| Platinum | Fjord Analytics | 2 |
| Silver | Sakura Logistics | 2 |
● 5 rows · produced by running this query on the sample database
Five rows for four tiers: Acme Robotics and Fjord Analytics placed two orders each, the most in Platinum, so both get rank 1 and both are kept. Swap RANK for ROW_NUMBER and Platinum shows only one of them, with nothing in the query to say which. So choose on purpose: ROW_NUMBER for exactly N rows, RANK to keep everyone tied at the cut-off (which can mean more than N rows), DENSE_RANK for the top N distinct values.
The query also shows the order of evaluation. GROUP BY runs before window functions, which is why RANK can
order by COUNT(*), a value that only exists once the rows have been grouped.
Running totals and moving averages ▶ runs here
An aggregate with an ORDER BY inside OVER accumulates, so SUM(order_total) OVER (ORDER BY
order_date) is a running total. Add a frame clause and AVG becomes a moving average:
SELECT order_date, order_total,
SUM(order_total) OVER (ORDER BY order_date) AS running_total,
ROUND(AVG(order_total) OVER (ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS avg_last_3
FROM Orders
ORDER BY order_date;
| order_date | order_total | running_total | avg_last_3 |
|---|---|---|---|
| 2024-01-14 | 15588 | 15588 | 15588 |
| 2024-01-22 | 2697 | 18285 | 9142.5 |
| 2024-02-02 | 3599.92 | 21884.92 | 7294.97 |
| 2024-02-11 | 4760 | 26644.92 | 3685.64 |
| 2024-02-19 | 2842.5 | 29487.42 | 3734.14 |
| 2024-03-01 | 2900 | 32387.42 | 3500.83 |
| 2024-03-08 | 6495 | 38882.42 | 4079.17 |
| 2024-03-15 | 2100 | 40982.42 | 3831.67 |
| 2024-03-27 | 4949.89 | 45932.31 | 4514.96 |
| 2024-04-04 | 8091 | 54023.31 | 5046.96 |
| 2024-04-12 | 3000 | 57023.31 | 5346.96 |
| 2024-04-25 | 5685 | 62708.31 | 5592 |
| 2024-05-06 | 5800 | 68508.31 | 4828.33 |
| 2024-05-18 | 1050 | 69558.31 | 4178.33 |
| 2024-05-29 | 9093 | 78651.31 | 5314.33 |
● 15 rows · produced by running this query on the sample database
Each running_total is the sum of every order up to and including that one, so the last row,
78651.31, is all fifteen orders together (cancelled ones included; add a WHERE to leave them out). This is
the cumulative-revenue line every dashboard has, in one expression.
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW is a frame of at most three rows: this order and
the two before it. At the start there are fewer rows to average, so the first avg_last_3 is
just the first order's 15588 and the second is the mean of two orders, 9142.5. The opening values of any
moving average rest on less data than the rest, which is worth saying on a chart.
One subtlety. When OVER has an ORDER BY but no frame, the default frame is
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and RANGE treats rows with the same
order_date as one step: two orders on the same day would both show a total that includes
both. Every order date in the sample is different, so it makes no difference here. Write
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW when you want a strict row-by-row total.
Comparing with the previous row: LAG and LEAD ▶ runs here
LAG reads a value from an earlier row of the window, LEAD from a later one. The classic use is "how much did this change since last time?" Here is each order beside the same customer's previous order, for three customers so the table stays short:
SELECT customer_id, order_date, order_total,
LAG(order_total) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_total,
order_total - LAG(order_total) OVER (PARTITION BY customer_id ORDER BY order_date) AS diff
FROM Orders
WHERE customer_id IN (101, 102, 105)
ORDER BY customer_id, order_date;
| customer_id | order_date | order_total | prev_total | diff |
|---|---|---|---|---|
| 101 | 2024-01-14 | 15588 | NULL | NULL |
| 101 | 2024-02-11 | 4760 | 15588 | -10828 |
| 101 | 2024-05-18 | 1050 | 4760 | -3710 |
| 102 | 2024-01-22 | 2697 | NULL | NULL |
| 102 | 2024-03-08 | 6495 | 2697 | 3798 |
| 105 | 2024-03-01 | 2900 | NULL | NULL |
| 105 | 2024-04-25 | 5685 | 2900 | 2785 |
● 7 rows · produced by running this query on the sample database
PARTITION BY keeps each customer's history separate, so every customer's first order has no previous row:
LAG returns NULL there, and so does diff, because anything minus NULL is NULL. Customer 101
went from 15588 to 4760, a change of -10828. Write LAG(order_total, 1, 0) to get 0 instead of
NULL on those first rows; the 1 is how many rows back to look. LEAD takes the same arguments, looks
forward, and returns NULL on each customer's last order.
WHERE runs before the window function, so LAG only ever sees the rows that survive it. Run the same pattern over monthly totals and you have month-over-month change; the SQL for data analysts guide builds exactly that.
Splitting rows into buckets: NTILE ▶ runs here
NTILE(n) deals the rows, in window order, into n numbered groups of as equal a size as
possible — the tool for quartiles, deciles and price bands:
SELECT name, unit_price,
NTILE(3) OVER (ORDER BY unit_price DESC) AS price_band
FROM Products
ORDER BY unit_price DESC;
| name | unit_price | price_band |
|---|---|---|
| Beacon Analytics | 1450 | 1 |
| Aurora Laptop 14" | 1299 | 1 |
| Atlas Cloud Suite | 899 | 1 |
| Vertex Monitor 27" | 449.99 | 2 |
| Harbor Support Plan | 400 | 2 |
| Nimbus Docking Hub | 189.5 | 3 |
| Quill Keyboard | 119 | 3 |
● 7 rows · produced by running this query on the sample database
Seven products do not divide evenly into three, so the bands hold three, two and two: when the rows do not split exactly, the earlier buckets get one extra row each. NTILE looks only at position, not at value, so two rows with the same value can land in different buckets. Check for ties before you label a bucket "the top third".
Try it yourself
Every example on this page runs in the playground. Change the column after ORDER BY or PARTITION BY inside OVER and watch the numbers follow, or swap RANK for ROW_NUMBER in the tier query and see Platinum lose a customer.
Open the PlaygroundWindow functions vs GROUP BY, in one sentence each
GROUP BY collapses rows to one per group and can only return group-level columns. Window functions keep every row and attach group-level (or position-level) values to each. When the question starts with "for each row…", it is a window function; when it starts with "how many / what total per group…", it is GROUP BY.
Frequently asked questions
What is a window function in SQL?
A function that computes a value for each row using other, related rows - its "window" - without collapsing them the way GROUP BY does. Rankings, running totals and per-group comparisons on every row are the classic uses.
What is the difference between ROW_NUMBER, RANK and DENSE_RANK?
They differ only on ties. ROW_NUMBER always gives distinct numbers, even to equal rows. RANK gives ties the same number and skips the next positions (1, 2, 2, 4). DENSE_RANK gives ties the same number without skipping (1, 2, 2, 3).
What does PARTITION BY do?
It splits the rows into separate windows - one per distinct value - and the function computes independently inside each, restarting at every boundary. It is like GROUP BY, except that every row is kept.
How do I get the top N rows per group in SQL?
Rank the rows inside each group with ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY sort_col DESC) in a CTE, then keep the rows where the rank is at most N in the outer query. Use RANK() instead when rows tied at the cut-off should all be kept.
Can I use a window function in a WHERE clause?
No - window functions are evaluated after WHERE. To filter on one, compute it in a CTE or subquery and filter in the outer query. That restriction is exactly why the top-N pattern is written in two steps.
Which databases support window functions?
PostgreSQL, SQL Server, Oracle, MySQL 8.0+ and SQLite 3.25+ all support the standard syntax used on this page. The playground on this site runs SQLite, so every example here can be run and changed in your browser.
Related reading
About the author
SQL Practice
SQL Practice publishes free SQL tutorials, an in-browser playground and 42 practice exercises, built so you can run what you read. Every runnable example in our tutorials is executed against the site’s sample database before it is published. Read how we keep tutorials accurate, or report a mistake.