Skip to content
Advanced SQL

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.

10 min readUpdated October 8, 2026Every example verified
On this page 11 sections ▾
  1. Numbering rows: ROW_NUMBER ▶ runs here
  2. A group value on every row: AVG OVER PARTITION BY ▶ runs here
  3. Ranking with ties: RANK and DENSE_RANK ▶ runs here
  4. The interview classic: top earner per department ▶ runs here
  5. Top N per group when there are ties ▶ runs here
  6. Running totals and moving averages ▶ runs here
  7. Comparing with the previous row: LAG and LEAD ▶ runs here
  8. Splitting rows into buckets: NTILE ▶ runs here
  9. Window functions vs GROUP BY, in one sentence each
  10. Frequently asked questions
  11. 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;
rnfirst_namesalary
1Chen156000
2Ananya142000
3Tomas131000
4Marcus118000
5Priya105000
6Leila96000
7Isabela92000
8Grace89500
9Sofia79000
10Hiroshi74500
11Daniel68000
12Omar61000

● 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_namedept_idsalarydept_avg
Ananya1014200099200
Marcus1011800099200
Leila109600099200
Sofia107900099200
Omar106100099200
Tomas20131000100000
Priya20105000100000
Grace2089500100000
Hiroshi2074500100000
Daniel306800068000
Chen50156000124000
Isabela5092000124000

● 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;
namein_stockrow_numrnkdense_rnk
Atlas Cloud Suite999111
Beacon Analytics999211
Harbor Support Plan999311
Quill Keyboard210442
Nimbus Docking Hub130553
Vertex Monitor 27"68664
Aurora Laptop 14"42775

● 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_namedept_idsalary
Ananya10142000
Tomas20131000
Daniel3068000
Chen50156000

● 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;
tiercompanyorders
BronzeBaltic Systems1
GoldMeridian Trading Co.3
PlatinumAcme Robotics2
PlatinumFjord Analytics2
SilverSakura Logistics2

● 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_dateorder_totalrunning_totalavg_last_3
2024-01-14155881558815588
2024-01-222697182859142.5
2024-02-023599.9221884.927294.97
2024-02-11476026644.923685.64
2024-02-192842.529487.423734.14
2024-03-01290032387.423500.83
2024-03-08649538882.424079.17
2024-03-15210040982.423831.67
2024-03-274949.8945932.314514.96
2024-04-04809154023.315046.96
2024-04-12300057023.315346.96
2024-04-25568562708.315592
2024-05-06580068508.314828.33
2024-05-18105069558.314178.33
2024-05-29909378651.315314.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_idorder_dateorder_totalprev_totaldiff
1012024-01-1415588NULLNULL
1012024-02-11476015588-10828
1012024-05-1810504760-3710
1022024-01-222697NULLNULL
1022024-03-08649526973798
1052024-03-012900NULLNULL
1052024-04-25568529002785

● 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;
nameunit_priceprice_band
Beacon Analytics14501
Aurora Laptop 14"12991
Atlas Cloud Suite8991
Vertex Monitor 27"449.992
Harbor Support Plan4002
Nimbus Docking Hub189.53
Quill Keyboard1193

● 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 Playground

Window 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.

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.