SQL Window Functions
SQL

SQL Window Functions: ROW_NUMBER, RANK, LAG and Running Totals

SQL Tutorial · Chapter 30 of 32

SQL window functions perform a calculation across a set of rows related to the current row, without collapsing those rows the way GROUP BY does. Each row keeps its own detail and gains an extra column: a rank, a running total, the previous row’s value or a group total. The OVER clause defines the “window” of rows the function looks at.

This chapter is for anyone who already knows SELECT, WHERE and GROUP BY and wants to write the kind of queries analysts use every day: numbering rows, ranking with ties, running totals, percent of group, comparing with the previous row, moving averages and top-N per group. Every example uses the same six-row Orders table and shows the exact output.

SQL Window Function Syntax

function_name(arguments) OVER (
    [PARTITION BY column]   -- restart the calculation for each group
    [ORDER BY column]       -- order rows inside the window
    [frame clause]          -- e.g. ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
)
Part What it means
function_name(arguments) A ranking, offset or aggregate function such as ROW_NUMBER(), LAG(Amount) or SUM(Amount)
OVER ( ) Turns the function into a window function; empty brackets mean “all rows of the result”
PARTITION BY Splits rows into groups; the calculation restarts in each group
ORDER BY Sets the row order inside each partition; required for ranking, LAG/LEAD and running totals
Frame clause Limits which rows around the current row are included, e.g. the last three rows for a moving average

Common Window Functions

Function Returns
ROW_NUMBER() 1, 2, 3 … with no ties
RANK() Rank with gaps after ties (1, 1, 3)
DENSE_RANK() Rank without gaps (1, 1, 2)
SUM / AVG / COUNT / MIN / MAX … OVER Aggregate over the window, kept on every row
LAG(col) / LEAD(col) Value from the previous / next row
FIRST_VALUE / LAST_VALUE / NTILE Edge values and bucket numbers

Sample Table

Orders
OrderID CustomerID Product Amount OrderDate
101 1 Laptop 1200 2026-01-05
102 2 Mouse 25 2026-01-07
103 1 Monitor 300 2026-01-12
104 3 Keyboard 45 2026-02-02
105 6 Headset 80 2026-02-15
106 4 Laptop 1150 2026-03-01

Example 1: ROW_NUMBER

SELECT OrderID, Amount,
       ROW_NUMBER() OVER (ORDER BY Amount DESC) AS RowNum
FROM Orders;
OrderID Amount RowNum
101 1200 1
106 1150 2
103 300 3
105 80 4
104 45 5
102 25 6

All six rows survive. Compare this with GROUP BY, which would return one row per group.

Example 2: RANK vs DENSE_RANK with Ties

Ranking orders by month creates ties: three orders share January.

SELECT OrderID, SUBSTR(OrderDate, 1, 7) AS Month,
       RANK()       OVER (ORDER BY SUBSTR(OrderDate, 1, 7)) AS Rnk,
       DENSE_RANK() OVER (ORDER BY SUBSTR(OrderDate, 1, 7)) AS DenseRnk
FROM Orders;
OrderID Month Rnk DenseRnk
101 2026-01 1 1
102 2026-01 1 1
103 2026-01 1 1
104 2026-02 4 2
105 2026-02 4 2
106 2026-03 6 3

RANK skips to 4 after three tied rows; DENSE_RANK continues with 2.

Example 3: Running Total with SUM OVER

SELECT OrderID, OrderDate, Amount,
       SUM(Amount) OVER (ORDER BY OrderDate) AS RunningTotal
FROM Orders;
OrderID OrderDate Amount RunningTotal
101 2026-01-05 1200 1200
102 2026-01-07 25 1225
103 2026-01-12 300 1525
104 2026-02-02 45 1570
105 2026-02-15 80 1650
106 2026-03-01 1150 2800

With ORDER BY inside OVER, the default frame runs from the first row up to the current row, which is exactly a running total. The last row, 2800, equals the sum of all six orders.

Example 4: PARTITION BY for Group Totals on Every Row

SELECT OrderID, CustomerID, Amount,
       SUM(Amount) OVER (PARTITION BY CustomerID) AS CustomerTotal,
       ROUND(100.0 * Amount / SUM(Amount) OVER (PARTITION BY CustomerID), 1) AS PctOfCustomer
FROM Orders
ORDER BY CustomerID, OrderID;
OrderID CustomerID Amount CustomerTotal PctOfCustomer
101 1 1200 1500 80.0
103 1 300 1500 20.0
102 2 25 25 100.0
104 3 45 45 100.0
106 4 1150 1150 100.0
105 6 80 80 100.0

Customer 1 has two orders totalling 1500, so the laptop is 1200 ÷ 1500 = 80% of that customer’s spend.

Example 5: LAG to Compare with the Previous Row

SELECT OrderID, OrderDate, Amount,
       LAG(Amount) OVER (ORDER BY OrderDate) AS PrevAmount,
       Amount - LAG(Amount) OVER (ORDER BY OrderDate) AS Change
FROM Orders;
OrderID OrderDate Amount PrevAmount Change
101 2026-01-05 1200 NULL NULL
102 2026-01-07 25 1200 -1175
103 2026-01-12 300 25 275
104 2026-02-02 45 300 -255
105 2026-02-15 80 45 35
106 2026-03-01 1150 80 1070

LEAD works the same way but looks at the next row. The first row has no predecessor, so LAG returns NULL; write LAG(Amount, 1, 0) to return 0 instead.

Example 6: Top-N per Group

Window functions cannot appear in WHERE, so wrap the query in a subquery or CTE and filter on the result. This returns each customer’s largest order.

WITH ranked AS (
  SELECT OrderID, CustomerID, Amount,
         ROW_NUMBER() OVER (PARTITION BY CustomerID ORDER BY Amount DESC) AS rn
  FROM Orders
)
SELECT OrderID, CustomerID, Amount
FROM ranked
WHERE rn = 1
ORDER BY CustomerID;
OrderID CustomerID Amount
101 1 1200
102 2 25
104 3 45
106 4 1150
105 6 80

Change rn = 1 to rn <= 3 for each customer’s top three orders. The subqueries chapter explains the wrapper in more detail.

Example 7: Moving Average with a ROWS Frame

A frame clause limits the window to nearby rows. Here each row averages its own Amount with the two previous orders.

SELECT OrderID, OrderDate, Amount,
       ROUND(AVG(Amount) OVER (ORDER BY OrderDate
             ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS MovingAvg3
FROM Orders;
OrderID OrderDate Amount MovingAvg3
101 2026-01-05 1200 1200.00
102 2026-01-07 25 612.50
103 2026-01-12 300 508.33
104 2026-02-02 45 123.33
105 2026-02-15 80 141.67
106 2026-03-01 1150 425.00

The first two rows average fewer than three values because there are not enough earlier rows yet: (1200 + 25) ÷ 2 = 612.50.

Example 8: NTILE to Split Rows into Buckets

SELECT OrderID, Amount,
       NTILE(2) OVER (ORDER BY Amount DESC) AS Half
FROM Orders;
OrderID Amount Half
101 1200 1
106 1150 1
103 300 1
105 80 2
104 45 2
102 25 2

NTILE(2) splits the six orders into a top half and a bottom half. NTILE(4) gives quartiles, a common way to segment customers by spend.

Works in MySQL, SQL Server, PostgreSQL, SQLite

  • Window functions are available in MySQL 8.0+, SQL Server 2012+ (for the full set including LAG, LEAD and running totals), PostgreSQL 8.4+ and SQLite 3.25+. MySQL 5.7 and older do not have them.
  • Month extraction differs: SQL Server uses FORMAT(OrderDate, 'yyyy-MM'), MySQL DATE_FORMAT, PostgreSQL TO_CHAR; the window syntax itself is identical.
  • In SQL Server, AVG of an integer column returns an integer (612 instead of 612.50 in Example 7). Write AVG(Amount * 1.0) to keep the decimals.
  • MySQL, PostgreSQL and SQLite support the WINDOW clause to name a window once and reuse it; SQL Server added it in SQL Server 2022.

Common Errors and Fixes

Problem Why it happens Fix
Error when a window function is used in WHERE or HAVING Window functions are calculated after WHERE, GROUP BY and HAVING Compute it in a subquery or CTE and filter in the outer query
ROW_NUMBER or LAG gives a different order each run No ORDER BY inside OVER, so the row order is arbitrary Always add ORDER BY inside OVER for ranking and offset functions
SUM() OVER (ORDER BY ...) returns a running total instead of a grand total Adding ORDER BY switches the default frame to “start to current row” Use SUM(Amount) OVER () for the grand total on every row
Running total jumps on rows with the same date The default frame is RANGE, which treats tied ORDER BY values as one step Add ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW or a tie-breaker column
LAST_VALUE returns the current row’s value The default frame ends at the current row Use ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING

Tips for Using Window Functions

  • Choose ROW_NUMBER when you need exactly one row per position, RANK when ties should share a place and leave gaps, and DENSE_RANK when ties should share a place without gaps.
  • Add a unique tie-breaker (for example OrderID) to ORDER BY inside OVER so results are repeatable.
  • Use the same window in several columns? Name it once with the WINDOW clause where your database supports it.
  • Window functions run on the rows left after WHERE, so filter first to keep large queries fast.

Practice Exercise

  1. Number each customer’s orders from oldest to newest.
  2. Show each order with the average Amount of all orders on the same row.
Show answers
-- 1
SELECT CustomerID, OrderID, OrderDate,
       ROW_NUMBER() OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS OrderSeq
FROM Orders;
-- customer 1: 101 = 1, 103 = 2; all others = 1

-- 2
SELECT OrderID, Amount, AVG(Amount) OVER () AS OverallAvg FROM Orders;
-- OverallAvg = 466.67 on every row

Related Chapters

Running totals, rankings and lookups also have spreadsheet equivalents, which the Excel AI Mastery: 100 Formulas course on NextGenTemplates Academy covers with practice files.

Frequently Asked Questions

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

GROUP BY merges rows into one row per group. A window function keeps every row and adds the aggregate, rank or offset value as a new column.

What does PARTITION BY do?

It splits the rows into groups so the window function restarts for each group, like GROUP BY but without collapsing the rows.

Can I use a window function in a WHERE clause?

No. Window functions are evaluated after WHERE. Put the query in a subquery or CTE and filter on the window column in the outer query.

What is the difference between RANK and DENSE_RANK?

Both give tied rows the same rank. RANK then skips numbers (1, 1, 3), while DENSE_RANK continues without gaps (1, 1, 2).

How do I calculate a running total in SQL?

Use SUM(column) OVER (ORDER BY date_column). Add PARTITION BY to restart the total for each customer, product or other group.

Does MySQL support window functions?

Yes, from MySQL 8.0. Older versions such as MySQL 5.7 do not, and need self-joins or variables to get similar results.

Wrapping Up

Window functions let you rank, total, compare and bucket rows while keeping every row in the result. Start with ROW_NUMBER and SUM OVER on your own tables, then move to LAG and the top-N-per-group pattern, which solve a large share of everyday reporting questions.

Test Yourself with the Free SQL Quiz

📅 Last updated: October 2026

Chapter 30 of 32 · SQL from Scratch: all 32 chapters

PK
Written by PK (Priyendra Kumar)
Microsoft Certified Professional with 15+ years in data analysis, dashboards and automation. Creator of PK: An Excel Expert and NeoTech Navigators on YouTube. About the author
Tested in the real tool · Last reviewed October 10, 2026
PK
Meet PK, the founder of NeotechNavigators.com! With over 15 years of experience in Data Visualization, Excel Automation, and dashboard creation. PK is a Microsoft Certified Professional who has a passion for all things in Excel. PK loves to explore new and innovative ways to use Excel and is always eager to share his knowledge with others. With an eye for detail and a commitment to excellence, PK has become a go-to expert in the world of Excel. Whether you're looking to create stunning visualizations or streamline your workflow with automation, PK has the skills and expertise to help you succeed. Join the many satisfied clients who have benefited from PK's services and see how he can take your data analysis skills to the next level!
https://neotechnavigators.com