Note ROW_NUMBER assigns a unique sequential number. Ties get arbitrary ordering — two rows with the same total_amount will get different numbers based on physical storage order. Use RANK or DENSE_RANK if you need tie handling.
row numbersequential numbernumber rowsrow_number
RANK and DENSE_RANK
syntax
RANK() OVER (ORDER BY column)
DENSE_RANK() OVER (ORDER BY column)
example
SELECT
product_name,
price,
RANK() OVER (ORDER BY price DESC) AS rank,
DENSE_RANK() OVER (ORDER BY price DESC) AS dense_rank
FROM products;
Note RANK leaves gaps after ties (1, 2, 2, 4). DENSE_RANK does not (1, 2, 2, 3). Choose based on whether you need contiguous numbers or positional accuracy.
rankdense rankrankingtop nposition in order
PARTITION BY
syntax
window_function() OVER (PARTITION BY column ORDER BY column)
example
SELECT
department_id,
first_name,
salary,
ROW_NUMBER() OVER (
PARTITION BY department_id
ORDER BY salary DESC
) AS dept_rank
FROM employees;
output
-- department_id | first_name | salary | dept_rank
-- 1 | Alice | 95000 | 1
-- 1 | Bob | 82000 | 2
-- 2 | Carol | 105000 | 1
-- 2 | Dave | 78000 | 2
Note PARTITION BY divides rows into groups and applies the window function independently within each group. It is like GROUP BY but without collapsing rows. You can partition by multiple columns.
partition bywindow per grouprank within grouptop n per category
LEAD and LAG
syntax
LAG(column, offset, default) OVER (ORDER BY column)
LEAD(column, offset, default) OVER (ORDER BY column)
example
SELECT
order_date,
total_amount,
LAG(total_amount) OVER (ORDER BY order_date) AS prev_amount,
total_amount - LAG(total_amount) OVER (ORDER BY order_date) AS change
FROM orders
WHERE user_id = 42;
Note LAG looks at previous rows; LEAD looks at subsequent rows. The optional second argument specifies how many rows to look back/ahead (default 1). The third argument provides a default value instead of NULL for the first/last row.
previous rownext rowlagleadcompare to previousrow before
SUM / AVG OVER (Running Totals)
syntax
SUM(column) OVER (ORDER BY column ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
example
SELECT
order_date,
total_amount,
SUM(total_amount) OVER (ORDER BY order_date) AS running_total,
AVG(total_amount) OVER (
ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM orders
WHERE user_id = 42;
Note Without ROWS/RANGE, the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which includes all rows with the same ORDER BY value. Use ROWS for exact row-based windows. This distinction matters when ORDER BY has ties.
Note NTILE divides rows into n approximately equal groups. If the row count is not evenly divisible, earlier groups get one extra row. Useful for percentile bucketing, but the buckets are based on row count, not value distribution.
ntilequartilepercentile bucketdivide into groupsequal groups
FIRST_VALUE / LAST_VALUE
syntax
FIRST_VALUE(column) OVER (ORDER BY column)
LAST_VALUE(column) OVER (ORDER BY column ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
example
SELECT
department_id,
first_name,
salary,
FIRST_VALUE(first_name) OVER (
PARTITION BY department_id
ORDER BY salary DESC
) AS top_earner
FROM employees;
output
-- Shows each employee alongside their department's top earner
Note LAST_VALUE has a critical gotcha: the default window frame ends at CURRENT ROW, not at the end of the partition. You must add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING to get the true last value.
first valuelast valuefirst in grouphighest in partition
Window Frame Specification
syntax
ROWS BETWEEN start AND end
-- start/end: UNBOUNDED PRECEDING | n PRECEDING | CURRENT ROW | n FOLLOWING | UNBOUNDED FOLLOWING
example
SELECT
sale_date,
amount,
AVG(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS weekly_moving_avg,
SUM(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS cumulative_total
FROM daily_sales;
output
-- 7-day moving average alongside cumulative total
Note ROWS counts physical rows. RANGE groups rows with the same ORDER BY value together. For date-based ranges in PostgreSQL, you can use RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW if the ORDER BY column is a date.
SELECT ...
FROM table
WINDOW w AS (PARTITION BY col ORDER BY col);
example
SELECT
department_id,
first_name,
salary,
ROW_NUMBER() OVER w AS dept_rank,
SUM(salary) OVER w AS running_salary,
AVG(salary) OVER w AS running_avg
FROM employees
WINDOW w AS (PARTITION BY department_id ORDER BY salary DESC);
output
-- Reuses the same window definition for three different functions
Note The WINDOW clause avoids repeating the same OVER specification. It is ANSI SQL and supported in PostgreSQL and MySQL 8+. You can still modify a named window in individual OVER clauses to add frame specs.
named windowwindow clausereuse windowwindow alias
PERCENT_RANK / CUME_DIST
syntax
PERCENT_RANK() OVER (ORDER BY column)
CUME_DIST() OVER (ORDER BY column)
example
SELECT
first_name,
salary,
PERCENT_RANK() OVER (ORDER BY salary) AS pct_rank,
CUME_DIST() OVER (ORDER BY salary) AS cumulative_dist
FROM employees;
output
-- first_name | salary | pct_rank | cumulative_dist
-- Dave | 50000 | 0.00 | 0.25
-- Carol | 65000 | 0.33 | 0.50
-- Bob | 82000 | 0.67 | 0.75
-- Alice | 95000 | 1.00 | 1.00
Note PERCENT_RANK returns (rank - 1) / (total - 1), ranging from 0 to 1. CUME_DIST returns the fraction of rows with values less than or equal to the current row. Both are useful for percentile analysis.
percent rankpercentilecumulative distributionrelative position