Window Functions in SQL
A window function calculates a value over related rows without collapsing the result into one row per group. PARTITION BY chooses the group, ORDER BY establishes the calculation order, and the frame (where applicable) controls which neighboring rows contribute. The most common errors come from ambiguous ties and implicit frames, so this chapter tests both explicitly.
The core model: partitions, order, and frames
Read the window in three parts
An aggregate with GROUP BY ProductID produces one result row per product. A windowed sum can put that same product total beside each original sale. It calculates across related rows without collapsing them.
Inside OVER, PARTITION BY chooses related rows, ORDER BY puts them in calculation order, and a frame selects which of those rows participate in the current calculation. A running total uses an expanding frame; a moving average can use a small frame around the current row. Ranking and offset functions use the partition and order with their own rules, rather than all behaving like framed sums.
Read aggregate functions first. Start here with row numbering and running totals, then compare ranks, earlier/later values, and moving averages. The sales example below is a separate practice database.
Functions and shared sample data
Common Window Functions
Here are some of the most commonly used window functions in SQL:
ROW_NUMBER(): Assigns a unique sequential integer to rows within a partition of a result set, starting at 1 for the first row in each partition.RANK(): Assigns a rank to each row within a partition of a result set, with gaps in ranking values when there are ties.DENSE_RANK(): Similar toRANK(), but without gaps in ranking values when there are ties.NTILE(): Distributes rows into a specified number of approximately equal groups.LEAD(): Provides access to a subsequent row’s data without the need for a self-join.LAG(): Provides access to a preceding row’s data without the need for a self-join.- Aggregate Window Functions: Such as
SUM(),AVG(),COUNT(), which perform aggregate calculations over a window.
Setting Up Example Tables
Run this setup once in a fresh SQLite database with window-function support. Continue using this database throughout the chapter; the null-value section adds sale 11 explicitly.
PRAGMA foreign_keys = ON;
CREATE TABLE Products (
ProductID INTEGER PRIMARY KEY,
ProductName TEXT NOT NULL,
Category TEXT NOT NULL
);
CREATE TABLE Sales (
SaleID INTEGER PRIMARY KEY,
ProductID INTEGER NOT NULL REFERENCES Products(ProductID),
SaleDate TEXT NOT NULL,
Quantity INTEGER,
Price REAL NOT NULL
);
INSERT INTO Products VALUES
(101, 'Widget A', 'Gadgets'), (102, 'Widget B', 'Gadgets'),
(103, 'Gizmo C', 'Widgets'), (104, 'Gizmo D', 'Widgets');
INSERT INTO Sales VALUES
(1, 101, '2024-01-05', 10, 15.00),
(2, 102, '2024-01-07', 5, 25.00),
(3, 101, '2024-01-10', 20, 15.00),
(4, 103, '2024-01-12', 7, 30.00),
(5, 102, '2024-01-15', 10, 25.00),
(6, 101, '2024-01-20', 15, 15.00),
(7, 103, '2024-01-22', 5, 30.00),
(8, 104, '2024-01-25', 12, 20.00),
(9, 102, '2024-01-28', 8, 25.00),
(10, 104, '2024-01-30', 10, 20.00);
Price is the per-unit sale price. REAL demonstrates the calculations simply; use an intentional exact representation for production money. These statements produce the following initial data:
Sales Table
| SaleID | ProductID | SaleDate | Quantity | Price |
|---|---|---|---|---|
| 1 | 101 | 2024-01-05 | 10 | 15.00 |
| 2 | 102 | 2024-01-07 | 5 | 25.00 |
| 3 | 101 | 2024-01-10 | 20 | 15.00 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 |
| 5 | 102 | 2024-01-15 | 10 | 25.00 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 |
| 7 | 103 | 2024-01-22 | 5 | 30.00 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 |
| 10 | 104 | 2024-01-30 | 10 | 20.00 |
Products Table
| ProductID | ProductName | Category |
|---|---|---|
| 101 | Widget A | Gadgets |
| 102 | Widget B | Gadgets |
| 103 | Gizmo C | Widgets |
| 104 | Gizmo D | Widgets |
Ordering and dialect assumptions
Examples use window-capable SQL engines. Date-range RANGE frames and interval syntax are engine-specific. With tied dates, ROW_NUMBER, LAG, and LEAD need an additional unique ordering column such as SaleID for deterministic results. Preserve ties deliberately when demonstrating RANK and DENSE_RANK. A window's ORDER BY does not sort the final result.
For running totals, specify a ROWS frame when you want one row at a time; the default ordered frame can include all peers sharing the ordering value. The later nullable-quantity examples assume sale 11 has been added to the initial sample.
Understanding the OVER Clause
Window functions use the OVER clause to define the window or the set of rows the function should operate on. The OVER clause can include:
PARTITION BY: Divides the result set into partitions to which the window function is applied.ORDER BY: Defines the logical order of rows within each partition.- Window Frame Specification: Specifies the subset of rows within the partition for frame-based calculations (e.g.,
ROWS BETWEEN).
Ranks, row numbers, and neighbor rows
ROW_NUMBER Function
The ROW_NUMBER() function assigns a unique sequential integer to rows within a partition, starting at 1 for the first row in each partition.
Example: Assigning Row Numbers to Sales per Product
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID) AS RowNum
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | RowNum |
|---|---|---|---|---|---|
| 1 | 101 | 2024-01-05 | 10 | 15.00 | 1 |
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 2 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 3 |
| 2 | 102 | 2024-01-07 | 5 | 25.00 | 1 |
| 5 | 102 | 2024-01-15 | 10 | 25.00 | 2 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 | 3 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 | 1 |
| 7 | 103 | 2024-01-22 | 5 | 30.00 | 2 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 | 1 |
| 10 | 104 | 2024-01-30 | 10 | 20.00 | 2 |
- The
ROW_NUMBER()function assigns a unique row number to each sale within itsProductIDpartition based on theSaleDate. - Useful for tasks like pagination or identifying specific rows within partitions.
RANK and DENSE_RANK Functions
Both RANK() and DENSE_RANK() assign a rank to each row within a partition. The difference lies in how they handle ties.
RANK(): Assigns the same rank to tied rows but leaves gaps in the ranking sequence.DENSE_RANK(): Assigns the same rank to tied rows without leaving gaps.
Example: Ranking Sales by Quantity per Product
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
RANK() OVER (PARTITION BY ProductID ORDER BY CASE WHEN Quantity IS NULL THEN 1 ELSE 0 END, Quantity DESC) AS Rank,
DENSE_RANK() OVER (PARTITION BY ProductID ORDER BY CASE WHEN Quantity IS NULL THEN 1 ELSE 0 END, Quantity DESC) AS DenseRank
FROM
Sales
ORDER BY
ProductID,
Quantity DESC;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | Rank | DenseRank |
|---|---|---|---|---|---|---|
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 1 | 1 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 2 | 2 |
| 1 | 101 | 2024-01-05 | 10 | 15.00 | 3 | 3 |
| 5 | 102 | 2024-01-15 | 10 | 25.00 | 1 | 1 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 | 2 | 2 |
| 2 | 102 | 2024-01-07 | 5 | 25.00 | 3 | 3 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 | 1 | 1 |
| 7 | 103 | 2024-01-22 | 5 | 30.00 | 2 | 2 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 | 1 | 1 |
| 10 | 104 | 2024-01-30 | 10 | 20.00 | 2 | 2 |
RANK()andDENSE_RANK()are useful for identifying the position of rows within partitions, especially when dealing with ties.
NTILE Function
The NTILE() function distributes the rows in an ordered partition into a specified number of roughly equal groups.
Example: Dividing Sales into Quartiles per Product
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
NTILE(4) OVER (PARTITION BY ProductID ORDER BY Quantity DESC) AS Quartile
FROM
Sales
ORDER BY
ProductID,
Quantity DESC;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | Quartile |
|---|---|---|---|---|---|
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 1 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 2 |
| 1 | 101 | 2024-01-05 | 10 | 15.00 | 3 |
| 5 | 102 | 2024-01-15 | 10 | 25.00 | 1 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 | 2 |
| 2 | 102 | 2024-01-07 | 5 | 25.00 | 3 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 | 1 |
| 7 | 103 | 2024-01-22 | 5 | 30.00 | 2 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 | 1 |
| 10 | 104 | 2024-01-30 | 10 | 20.00 | 2 |
- The
NTILE(4)function divides eachProductIDpartition into four quartiles based onQuantity. - Useful for categorizing data into percentile-based groups.
LEAD and LAG Functions
LEAD() and LAG() functions allow you to access subsequent and preceding rows' data without the need for self-joins.
Example: Comparing Current Sale with Previous Sale Quantity per Product
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
LAG(Quantity, 1) OVER (PARTITION BY ProductID ORDER BY SaleDate) AS PreviousQuantity,
Quantity - LAG(Quantity, 1) OVER (PARTITION BY ProductID ORDER BY SaleDate) AS QuantityChange
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | PreviousQuantity | QuantityChange |
|---|---|---|---|---|---|---|
| 1 | 101 | 2024-01-05 | 10 | 15.00 | NULL | NULL |
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 10 | 10 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 20 | -5 |
| 2 | 102 | 2024-01-07 | 5 | 25.00 | NULL | NULL |
| 5 | 102 | 2024-01-15 | 10 | 25.00 | 5 | 5 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 | 10 | -2 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 | NULL | NULL |
| 7 | 103 | 2024-01-22 | 5 | 30.00 | 7 | -2 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 | NULL | NULL |
| 10 | 104 | 2024-01-30 | 10 | 20.00 | 12 | -2 |
LAG(Quantity, 1)retrieves the quantity from the previous sale within the sameProductIDpartition.LEAD()can similarly retrieve data from subsequent rows.- Useful for trend analysis and calculating differences between consecutive rows.
Running totals and moving frames
Aggregate Window Functions
Aggregate functions like SUM(), AVG(), and COUNT() can also be used as window functions to perform calculations across a window of rows.
Example: Calculating Running Total of Sales Amount per Product
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
Quantity * Price AS SaleAmount,
SUM(Quantity * Price) OVER (PARTITION BY ProductID ORDER BY SaleDate ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RunningTotal
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | SaleAmount | RunningTotal |
|---|---|---|---|---|---|---|
| 1 | 101 | 2024-01-05 | 10 | 15.00 | 150.00 | 150.00 |
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 300.00 | 450.00 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 225.00 | 675.00 |
| 2 | 102 | 2024-01-07 | 5 | 25.00 | 125.00 | 125.00 |
| 5 | 102 | 2024-01-15 | 10 | 25.00 | 250.00 | 375.00 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 | 200.00 | 575.00 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 | 210.00 | 210.00 |
| 7 | 103 | 2024-01-22 | 5 | 30.00 | 150.00 | 360.00 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 | 240.00 | 240.00 |
| 10 | 104 | 2024-01-30 | 10 | 20.00 | 200.00 | 440.00 |
SUM(Quantity * Price) OVER (...)calculates a running total of sales amounts within eachProductIDpartition ordered bySaleDate.- The window frame
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWdefines the range from the first row to the current row for cumulative calculations.
Handling NULL Values in Window Functions
Window functions typically handle NULL values based on the specific function's behavior. For example, SUM() ignores NULL values, while ROW_NUMBER() assigns a unique number regardless of NULLs.
Example: Assigning Row Numbers with NULL Quantities
Suppose we have an additional sale with a NULL quantity.
Before the examples in this section and all later sections, add the following sale once:
INSERT INTO Sales VALUES (11, 101, '2024-01-25', NULL, 15.00);
The additional row
| SaleID | ProductID | SaleDate | Quantity | Price |
|---|---|---|---|---|
| 11 | 101 | 2024-01-25 | NULL | 15.00 |
Query: Assigning Row Numbers Including NULL Quantities
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID) AS RowNum
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | RowNum |
|---|---|---|---|---|---|
| 1 | 101 | 2024-01-05 | 10 | 15.00 | 1 |
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 2 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 3 |
| 11 | 101 | 2024-01-25 | NULL | 15.00 | 4 |
| ... | ... | ... | ... | ... | ... |
- The
ROW_NUMBER()function assigns a row number to the sale withNULLquantity without any issues. - Other window functions may handle
NULLvalues differently, depending on their logic.
Readability and filtering
Give calculated columns clear aliases, such as RowNum or RunningTotal. Use the same partition and ordering consistently when the calculations describe the same sequence. Add a unique tie-breaker for row numbering and earlier/later comparisons, but preserve ties deliberately for ranking.
To filter by a window result, calculate it in a CTE or subquery and filter in an outer query. A window result is not available to the same query level's WHERE clause. The later top-two-sales example demonstrates the complete pattern.
A window may require sorting many rows. Inspect the execution plan on representative data rather than assuming a shorter query must run faster.
Windows inside larger queries
Window Functions vs. Aggregate Functions
While both window functions and aggregate functions perform calculations over sets of rows, they differ in key ways:
- Row Retention:
- Aggregate Functions: Collapse multiple rows into a single summary row per group.
-
Window Functions: Retain individual row identities while providing additional calculated data.
-
Usage with
GROUP BY: - Aggregate Functions: Use
GROUP BYfor multiple groups; without it, aggregate over the entire input. - Window Functions: Use the
OVERclause to define partitions and ordering without collapsing rows.
Example: Comparing Aggregate and Window Functions
Aggregate Function Example: Total Sales per Product
SELECT
ProductID,
SUM(Quantity * Price) AS TotalSales
FROM
Sales
GROUP BY
ProductID;
Result:
| ProductID | TotalSales |
|---|---|
| 101 | 675.00 |
| 102 | 575.00 |
| 103 | 360.00 |
| 104 | 440.00 |
Window Function Example: Total Sales per Product Alongside Each Sale
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
SUM(Quantity * Price) OVER (PARTITION BY ProductID) AS TotalSalesPerProduct
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result:
| SaleID | ProductID | TotalSalesPerProduct |
|---|---|---|
| 1 | 101 | 675.00 |
| 3 | 101 | 675.00 |
| 6 | 101 | 675.00 |
| 11 | 101 | 675.00 |
| 2 | 102 | 575.00 |
| 5 | 102 | 575.00 |
| 9 | 102 | 575.00 |
| 4 | 103 | 360.00 |
| 7 | 103 | 360.00 |
| 8 | 104 | 440.00 |
| 10 | 104 | 440.00 |
- The aggregate function provides a summarized view with one row per
ProductID. - The window function adds the total sales per product to each individual sale row without reducing the number of rows.
Combining Multiple Window Functions
You can use multiple window functions within a single query to perform various analyses simultaneously.
Example: Sales Analysis with Multiple Window Functions
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID) AS RowNum,
RANK() OVER (PARTITION BY ProductID ORDER BY CASE WHEN Quantity IS NULL THEN 1 ELSE 0 END, Quantity DESC) AS QuantityRank,
SUM(Quantity * Price) OVER (PARTITION BY ProductID ORDER BY SaleDate ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RunningTotal
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | RowNum | QuantityRank | RunningTotal |
|---|---|---|---|---|---|---|---|
| 1 | 101 | 2024-01-05 | 10 | 15.00 | 1 | 3 | 150.00 |
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 2 | 1 | 450.00 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 3 | 2 | 675.00 |
| 11 | 101 | 2024-01-25 | NULL | 15.00 | 4 | 4 | 675.00 |
| 2 | 102 | 2024-01-07 | 5 | 25.00 | 1 | 3 | 125.00 |
| 5 | 102 | 2024-01-15 | 10 | 25.00 | 2 | 1 | 375.00 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 | 3 | 2 | 575.00 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 | 1 | 1 | 210.00 |
| 7 | 103 | 2024-01-22 | 5 | 30.00 | 2 | 2 | 360.00 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 | 1 | 1 | 240.00 |
| 10 | 104 | 2024-01-30 | 10 | 20.00 | 2 | 2 | 440.00 |
ROW_NUMBER()assigns a unique row number within eachProductIDpartition.RANK()assigns ranks based onQuantitywithin eachProductID.SUM() OVER (...)calculates a running total of sales amounts perProductID.
Make the frame boundary explicit
Window Frames
Window frames define the subset of rows within the partition to be used for calculations in window functions. They are specified using the ROWS BETWEEN or RANGE BETWEEN clauses.
Example: Calculating a Moving Average of Quantity
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
AVG(Quantity) OVER (
PARTITION BY ProductID
ORDER BY SaleDate
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS MovingAverage
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | MovingAverage |
|---|---|---|---|---|---|
| 1 | 101 | 2024-01-05 | 10 | 15.00 | 10.00 |
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 15.00 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 15.00 |
| 11 | 101 | 2024-01-25 | NULL | 15.00 | 17.50 |
| 2 | 102 | 2024-01-07 | 5 | 25.00 | 5.00 |
| 5 | 102 | 2024-01-15 | 10 | 25.00 | 7.50 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 | 7.67 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 | 7.00 |
| 7 | 103 | 2024-01-22 | 5 | 30.00 | 6.00 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 | 12.00 |
| 10 | 104 | 2024-01-30 | 10 | 20.00 | 11.00 |
- The
AVG(Quantity)function calculates the average quantity over the current row and the two preceding rows within eachProductIDpartition. - Useful for trend analysis and smoothing out short-term fluctuations.
Practical Use Cases for Window Functions
- Pagination: Assign row numbers to implement pagination in queries.
- Running Totals and Moving Averages: Calculate cumulative sums or averages over a specified window.
- Ranking and Top-N Analysis: Determine the top performers or items within categories.
- Comparative Analysis: Compare current row values with previous or next rows without self-joins.
- Data Transformation: Restructure data for reporting and analytics purposes.
Combining Window Functions with Other SQL Features
Window functions can be combined with Common Table Expressions (CTEs), subqueries, and other SQL constructs to perform complex data transformations and analyses.
Example: Identifying Top 2 Sales per Product
WITH RankedSales AS (
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
RANK() OVER (PARTITION BY ProductID ORDER BY CASE WHEN Quantity IS NULL THEN 1 ELSE 0 END, Quantity DESC) AS SaleRank
FROM
Sales
)
SELECT
rs.SaleID,
rs.ProductID,
rs.SaleDate,
rs.Quantity,
rs.Price
FROM
RankedSales rs
WHERE
rs.SaleRank <= 2
ORDER BY
rs.ProductID,
rs.SaleRank;
Result
| SaleID | ProductID | SaleDate | Quantity | Price |
|---|---|---|---|---|
| 3 | 101 | 2024-01-10 | 20 | 15.00 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 |
| 5 | 102 | 2024-01-15 | 10 | 25.00 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 |
| 7 | 103 | 2024-01-22 | 5 | 30.00 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 |
| 10 | 104 | 2024-01-30 | 10 | 20.00 |
- The CTE
RankedSalesassigns a rank to each sale based onQuantitywithin eachProductID. - The outer query filters to include the top 2 ranks per product. If quantities tie at the cutoff,
RANK() <= 2can return more than two rows for that product.
Incorrect if exactly two rows are required: treat RANK() as a fixed-size top-N selector. For example, these independent SQLite rows contain a tie for second place:
WITH scored(sale_id, quantity) AS (
VALUES (1, 10), (2, 9), (3, 9)
)
SELECT sale_id, quantity,
RANK() OVER (ORDER BY quantity DESC) AS sale_rank,
ROW_NUMBER() OVER (ORDER BY quantity DESC, sale_id) AS row_position
FROM scored
ORDER BY row_position;
| sale_id | quantity | sale_rank | row_position |
|---|---|---|---|
| 1 | 10 | 1 | 1 |
| 2 | 9 | 2 | 2 |
| 3 | 9 | 2 | 3 |
Filtering by sale_rank <= 2 retains three rows. Filtering by row_position <= 2 retains exactly two rows, using sale_id to choose deterministically among tied quantities. Use RANK when ties should be preserved and ROW_NUMBER when the requirement is a fixed row count.
Comparing Window Functions with Self-Joins
Before window functions were widely supported, similar analyses often required complex self-joins. Window functions simplify these operations, making queries more readable and efficient.
Example: Using Window Functions vs. Self-Joins to Compare Current and Previous Sales
Using Window Functions
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
LAG(Quantity) OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID) AS PreviousQuantity
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result:
| SaleID | ProductID | PreviousQuantity |
|---|---|---|
| 1 | 101 | NULL |
| 3 | 101 | 10 |
| 6 | 101 | 20 |
| 11 | 101 | 15 |
| 2 | 102 | NULL |
| 5 | 102 | 5 |
| 9 | 102 | 10 |
| 4 | 103 | NULL |
| 7 | 103 | 7 |
| 8 | 104 | NULL |
| 10 | 104 | 12 |
Using Self-Joins
SELECT
s1.SaleID,
s1.ProductID,
s1.SaleDate,
s1.Quantity,
s1.Price,
s2.Quantity AS PreviousQuantity
FROM
Sales s1
LEFT JOIN
Sales s2
ON s1.ProductID = s2.ProductID
AND s1.SaleDate > s2.SaleDate
AND NOT EXISTS (
SELECT 1
FROM Sales s3
WHERE s3.ProductID = s1.ProductID
AND s3.SaleDate > s2.SaleDate
AND s3.SaleDate < s1.SaleDate
)
ORDER BY
s1.ProductID,
s1.SaleDate;
Result:
| SaleID | ProductID | PreviousQuantity |
|---|---|---|
| 1 | 101 | NULL |
| 3 | 101 | 10 |
| 6 | 101 | 20 |
| 11 | 101 | 15 |
| 2 | 102 | NULL |
| 5 | 102 | 5 |
| 9 | 102 | 10 |
| 4 | 103 | NULL |
| 7 | 103 | 7 |
| 8 | 104 | NULL |
| 10 | 104 | 12 |
- The window function directly expresses the previous-row operation and is usually easier to read. Compare plans to determine its performance benefit.
- The self-join approach is more complex and can be less efficient, especially on large datasets.
Limitations and Considerations
- Performance: The ranking examples put null quantities last explicitly so the result is independent of the engine’s default null ordering. Window functions can be resource-intensive on large datasets. Proper indexing and query optimization are crucial.
- Compatibility: Ensure that your SQL database system supports the window functions you intend to use (e.g., PostgreSQL, SQL Server, Oracle, MySQL 8.0+).
- Complexity: While window functions simplify many operations, overusing them or using them in overly complex ways can make queries harder to maintain.
- Understanding Window Frames: Properly defining window frames is essential for accurate calculations, especially for running totals and moving averages.
Tricky frames, ties, and temporal semantics
Further frame choices
ROWS counts positions in the ordered sequence. For example, ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING includes the previous row, current row, and next row where present. It does not mean “the previous and next day.” The existing moving-average query shows a runnable row-based frame.
RANGE works with ordering values and peer groups rather than simply counting rows. Date-based range offsets and interval syntax depend on the engine; a PostgreSQL interval expression cannot be pasted unchanged into this SQLite exercise. Multiple-column partitioning is also possible when several fields define the group, provided those columns actually exist in the input.
Before using a more advanced frame, state which rows should contribute for a concrete current row, then compare that prediction with the result. Frame clauses affect aggregate windows differently from ranking and offset functions.
Example Use Case: Sales Trend Analysis
Suppose you want to analyze sales trends by calculating the percentage change in quantity compared to the previous sale for each product.
SELECT
SaleID,
ProductID,
SaleDate,
Quantity,
Price,
LAG(Quantity) OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID) AS PreviousQuantity,
CASE
WHEN LAG(Quantity) OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID) IS NULL
OR LAG(Quantity) OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID) = 0 THEN NULL
ELSE ((Quantity - LAG(Quantity) OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID)) * 100.0) / LAG(Quantity) OVER (PARTITION BY ProductID ORDER BY SaleDate, SaleID)
END AS PercentageChange
FROM
Sales
ORDER BY
ProductID,
SaleDate;
Result
| SaleID | ProductID | SaleDate | Quantity | Price | PreviousQuantity | PercentageChange |
|---|---|---|---|---|---|---|
| 1 | 101 | 2024-01-05 | 10 | 15.00 | NULL | NULL |
| 3 | 101 | 2024-01-10 | 20 | 15.00 | 10 | 100.00 |
| 6 | 101 | 2024-01-20 | 15 | 15.00 | 20 | -25.00 |
| 11 | 101 | 2024-01-25 | NULL | 15.00 | 15 | NULL |
| 2 | 102 | 2024-01-07 | 5 | 25.00 | NULL | NULL |
| 5 | 102 | 2024-01-15 | 10 | 25.00 | 5 | 100.00 |
| 9 | 102 | 2024-01-28 | 8 | 25.00 | 10 | -20.00 |
| 4 | 103 | 2024-01-12 | 7 | 30.00 | NULL | NULL |
| 7 | 103 | 2024-01-22 | 5 | 30.00 | 7 | -28.57 |
| 8 | 104 | 2024-01-25 | 12 | 20.00 | NULL | NULL |
| 10 | 104 | 2024-01-30 | 10 | 20.00 | 12 | -16.67 |
- This query calculates the percentage change in
Quantitycompared to the previous sale for eachProductID. - Useful for identifying trends, growth, or decline in sales over time.
See ties change a running total
An independent three-row example makes frame differences visible without changing Sales:
WITH tied_sales(sale_id, amount) AS (
VALUES (1, 100), (2, 100), (3, 200)
)
SELECT sale_id, amount,
ROW_NUMBER() OVER (ORDER BY amount, sale_id) AS row_number,
RANK() OVER (ORDER BY amount) AS rank_with_gaps,
DENSE_RANK() OVER (ORDER BY amount) AS dense_rank,
SUM(amount) OVER (
ORDER BY amount
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS peer_total,
SUM(amount) OVER (
ORDER BY amount, sale_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS row_total
FROM tied_sales
ORDER BY amount, sale_id;
| sale_id | amount | row_number | rank_with_gaps | dense_rank | peer_total | row_total |
|---|---|---|---|---|---|---|
| 1 | 100 | 1 | 1 | 1 | 200 | 100 |
| 2 | 100 | 2 | 1 | 1 | 200 | 200 |
| 3 | 200 | 3 | 3 | 2 | 400 | 400 |
Both 100-valued rows are peers under ORDER BY amount. The range frame includes that whole peer group at its current boundary, so its first displayed total is already 200. The rows frame has a unique order and advances one row at a time. Adding sale_id to the ranking order would also break the tie, making those ranks different. Use tie-breaking only where it matches the desired meaning.
The outer ORDER BY controls presentation. The orders inside OVER control calculations. A result may display newest-first while computing a cumulative total oldest-first; make both choices explicit.
Ask what “last” means for LAST_VALUE
The default ordered frame commonly ends at the current row's peer group, rather than the end of the partition. That can make LAST_VALUE look unexpectedly like the current value:
Input (sample_values):
| id | amount |
|---|---|
| 1 | 100 |
| 2 | 150 |
| 3 | 200 |
WITH sample_values(id, amount) AS (VALUES (1, 100), (2, 150), (3, 200))
SELECT id,
LAST_VALUE(amount) OVER (ORDER BY id) AS last_in_default_frame,
LAST_VALUE(amount) OVER (
ORDER BY id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_in_partition
FROM sample_values
ORDER BY id;
Result:
| id | last_in_default_frame | last_in_partition |
|---|---|---|
| 1 | 100 | 200 |
| 2 | 150 | 200 |
| 3 | 200 | 200 |
UNBOUNDED FOLLOWING is necessary when the desired answer lies after the current row. LAG and LEAD have different offset semantics and should not be explained as though they simply aggregate the same frame.
A time interval is different from a fixed number of rows
Three recent sale rows might cover three minutes or three months. For a time-based report, use the engine's supported time-range syntax. This PostgreSQL example is independent of the SQLite Sales table:
Input (dated_sales):
| sale_id | sold_at | quantity |
|---|---|---|
| 1 | 2025-01-01 12:00 | 10 |
| 2 | 2025-01-03 12:00 | 20 |
| 3 | 2025-01-10 12:00 | 30 |
-- PostgreSQL
WITH dated_sales(sale_id, sold_at, quantity) AS (
VALUES
(1, TIMESTAMP '2025-01-01 12:00:00', 10),
(2, TIMESTAMP '2025-01-03 12:00:00', 20),
(3, TIMESTAMP '2025-01-10 12:00:00', 30)
)
SELECT sale_id,
AVG(quantity) OVER (
ORDER BY sold_at
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
) AS seven_day_average,
AVG(quantity) OVER (
ORDER BY sold_at, sale_id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS three_row_average
FROM dated_sales
ORDER BY sale_id;
Result:
| sale_id | seven_day_average | three_row_average |
|---|---|---|
| 1 | 10 | 10 |
| 2 | 15 | 15 |
| 3 | 25 | 20 |
On January 10, the seven-day range includes January 3 at the boundary and January 10, excluding January 1. The three-row frame includes all three sales. Neither calculates an average per calendar day including days with no sales: that requires generating or joining a calendar and deciding how empty days contribute.
An offset range frame in PostgreSQL requires a single ordering expression. Its timestamp interval does not use the same spelling as SQLite's numeric RANGE offsets. For SQLite, a suitable numeric time expression such as julianday(...) can be used with a deliberately chosen numeric-day offset; do not paste PostgreSQL's interval literal into it.
Build the partition at the right stage
Window functions operate on the rows supplied by the query after its relevant filtering and grouping. If WHERE removes cancelled orders, a later windowed total excludes them too. If GROUP BY produces one row per month, a window can calculate a running total across those monthly summaries. It is not operating on the original order lines at that stage.
To display only the top ranked rows, calculate ranks in a CTE or subquery, then filter the rank outside it. Filtering input rows before ranking answers a different question. For “latest sale for each product,” rank by date descending and sale ID descending so same-date ties have a defined winner. For “all sales tied for greatest amount,” use a tie-preserving rank instead.
Window calculations may require sorting and buffering partitions. Multiple incompatible orders can require additional work, and a full-partition last value may need rows beyond the current one. An index can help some plans, but it does not guarantee that every window runs without a sort. Inspect plans with representative data and keep long analytical queries' transaction and memory requirements in view.
References: SQLite window frames and PostgreSQL window syntax.
Review questions
- What are window functions in SQL, and how do they differ from aggregate functions?
- What are some common window functions, and what are their typical use cases?
- How does the
OVERclause define the window for window functions, and what components can it include? - What are the advantages and limitations of using window functions compared to traditional methods like self-joins?
- What best practices should be followed when implementing window functions in SQL to ensure optimal performance and maintainability?