Mastering SQL Window Functions: A Comprehensive Guide

SQL Window functions are one of the most powerful and most underused features in SQL. Introduced in SQL:2003 and supported by all major databases — PostgreSQL, SQL Server, MySQL 8+, Oracle, and BigQuery — they allow you to perform calculations across a set of rows that are related to the current row, without collapsing the result set the way GROUP BY does.

The name “window” refers to the sliding frame of rows that each function looks at when computing its result. Think of it as a porthole that moves row by row through your data, giving each row its own computed value based on its neighbourhood.

This blog covers every category of window function: the OVER clause and its anatomy, ranking functions, aggregate window functions, offset functions (LAG/LEAD), distribution functions, and frame specifications — each illustrated with practical, real-world SQL examples.

The fundamental difference between a window function and GROUP BY is that GROUP BY collapses rows into a single summary row per group. A window function computes a value for each row individually while still having access to the surrounding rows.

FeatureGROUP BY + AggregateWindow Function
Rows in outputOne row per groupAll original rows preserved
Other columnsMust be in GROUP BY or aggregatedAll columns freely selectable
Row contextNo access to individual rowsEach row can see its peers
Use in WHEREUse HAVING for aggregatesCannot use in WHERE / HAVING directly
Ordering within groupNot possibleORDER BY inside OVER()

-- GROUP BY: collapses to 1 row per department
SELECT department,
AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
-- Window function: keeps ALL rows, adds avg per department alongside
SELECT employee_id,
name,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg_salary
FROM employees;

Key insight: with a window function you can compare each employee’s salary to their department average in a single query — impossible with GROUP BY alone.

Every window function is paired with an OVER() clause. The OVER clause is what transforms an ordinary aggregate or ranking function into a window function. It has three optional sub-clauses that together define the window:

function_name(expression)
OVER (
PARTITION BY col1, col2 -- divide rows into independent groups
ORDER BY col3 ASC -- sort rows within each partition
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- frame
)

Sub-clauseRequired?Purpose
PARTITION BYNoDivides rows into partitions (like GROUP BY but without collapsing). Omit to treat the entire result as one partition.
ORDER BYSometimesDefines the ordering within each partition. Required for ranking and offset functions; optional for aggregates.
frame clauseNoRestricts the physical rows the function considers. Defaults differ by function type (see Section 9).

Ranking functions assign a rank number to each row within a partition based on an ORDER BY expression. They are the most commonly used window functions in analytics.

FunctionTies HandledGaps After TiesExample Output (ties at 2nd)
ROW_NUMBER()Arbitrary unique numberN/A1, 2, 3, 4
RANK()Same rank for tiesYes1, 2, 2, 4
DENSE_RANK()Same rank for tiesNo1, 2, 2, 3
NTILE(n)Divides into n equal bucketsN/A1, 1, 2, 2

Assigns a unique sequential integer to each row within a partition. Ties get different numbers (arbitrary order unless ORDER BY is deterministic).

SELECT
employee_id,
name,
department,
salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num
FROM employees;
-- Result: within each department, employees numbered 1,2,3... by salary desc
-- Useful for: deduplication, pagination, top-N per group

Both assign the same rank to tied rows. RANK() skips the next rank after a tie; DENSE_RANK() does not.

SELECT
name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dense_rnk
FROM employees;
-- salary: 95000, 90000, 90000, 80000
-- RANK: 1, 2, 2, 4 (gap at 3)
-- DENSE_RANK: 1, 2, 2, 3 (no gap)

NTILE(n) divides rows within a partition into n roughly equal buckets and assigns the bucket number. Ideal for creating quartiles, deciles, or percentile bands.

-- Divide employees into salary quartiles within each department
SELECT
name,
department,
salary,
NTILE(4) OVER (PARTITION BY department ORDER BY salary DESC) AS quartile
FROM employees;
-- quartile = 1 -> top 25%, quartile = 4 -> bottom 25%

One of the most common window function patterns: find the top N rows per group (e.g., top 3 salaries per department) without a correlated subquery.

WITH ranked AS (
SELECT
name,
department,
salary,
DENSE_RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dr
FROM employees
)
SELECT name, department, salary
FROM ranked
WHERE dr <= 3
ORDER BY department, salary DESC;

All standard aggregate functions — SUM, AVG, COUNT, MIN, MAX — can be used as window functions by adding an OVER clause. Unlike GROUP BY aggregates, they return a value for every row while still computing across the defined window.

SELECT
order_date,
order_id,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY customer_id, order_date;
-- Each row shows the cumulative spend for that customer up to that order

-- 7-day rolling average of daily sales
SELECT
sale_date,
daily_revenue,
AVG(daily_revenue) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS rolling_7day_avg
FROM daily_sales
ORDER BY sale_date;

-- Each product's revenue as % of its category total
SELECT
product_name,
category,
revenue,
ROUND(
revenue * 100.0 / SUM(revenue) OVER (PARTITION BY category),
2
) AS pct_of_category
FROM product_sales
ORDER BY category, revenue DESC;

-- Employee salary vs. department average and overall company average
SELECT
name,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
AVG(salary) OVER () AS company_avg,
salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_dept_avg
FROM employees
ORDER BY department, salary DESC;

LAG and LEAD access values from previous and next rows respectively, without a self-join. They are indispensable for period-over-period comparisons, detecting changes, and calculating differences between consecutive rows.

FunctionReturnsDefault for Missing Rows
LAG(col, n, default)Value from n rows BEFORE current rowNULL (or specified default)
LEAD(col, n, default)Value from n rows AFTER current rowNULL (or specified default)

SELECT
month,
revenue,
LAG(revenue, 1, 0) OVER (ORDER BY month) AS prev_month_revenue,
revenue - LAG(revenue, 1, 0) OVER (ORDER BY month) AS mom_change,
ROUND(
(revenue - LAG(revenue, 1) OVER (ORDER BY month))
* 100.0 / NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
2
) AS mom_pct_change
FROM monthly_revenue
ORDER BY month;

SELECT
user_id,
session_id,
page_visited,
LAG(page_visited) OVER (PARTITION BY user_id ORDER BY visited_at) AS prev_page
FROM page_views
HAVING page_visited = prev_page -- consecutive same-page visits
ORDER BY user_id, visited_at;

SELECT
customer_id,
order_date,
LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order_date,
DATEDIFF(
LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date),
order_date
) AS days_until_next_order
FROM orders
ORDER BY customer_id, order_date;

These functions return specific values from the first, last, or nth row in the current window frame. They are useful for carrying forward a baseline value or comparing any row against an anchor.

SELECT
name,
department,
salary,
-- Highest salary in the department (first after ORDER BY salary DESC)
FIRST_VALUE(salary) OVER (
PARTITION BY department ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS dept_max_salary,
-- Lowest salary in the department
LAST_VALUE(salary) OVER (
PARTITION BY department ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS dept_min_salary
FROM employees;
-- NOTE: LAST_VALUE requires explicit UNBOUNDED FOLLOWING frame;
-- otherwise the default frame ends at CURRENT ROW.

Always specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING with LAST_VALUE — otherwise the default frame stops at the current row, making LAST_VALUE identical to the current row’s value.

-- Second highest salary per department
SELECT
name,
department,
salary,
NTH_VALUE(salary, 2) OVER (
PARTITION BY department ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS second_highest_salary
FROM employees;

Distribution functions compute the relative position of a row within its partition as a value between 0 and 1. They are used in statistical analysis and for building histograms or percentile buckets.

FunctionFormulaRangeInterpretation
PERCENT_RANK()(rank – 1) / (total_rows – 1)0.0 to 1.0Percentage of rows ranked BELOW the current row
CUME_DIST()rank / total_rows1/n to 1.0Percentage of rows with value <= current row
SELECT
name,
salary,
PERCENT_RANK() OVER (ORDER BY salary) AS pct_rank,
CUME_DIST() OVER (ORDER BY salary) AS cumulative_dist
FROM employees
ORDER BY salary;
-- pct_rank = 0.0 for the lowest salary, 1.0 for the highest
-- cume_dist = fraction of employees earning <= this salary

Use CUME_DIST to answer: “What fraction of employees earn at most $X?” Use PERCENT_RANK for true percentile rankings (e.g., “this employee is in the 85th percentile”).

The frame clause is the most nuanced part of window functions. It defines exactly which rows within the partition are included in the calculation for the current row. Two frame modes exist: ROWS (physical row offsets) and RANGE (logical value offsets).

Frame ClauseMeaning
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWAll rows from start of partition to current row (running total)
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWCurrent row and 2 rows before it (rolling 3-row window)
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWINGCurrent row to end of partition
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWINGAll rows in the partition (global aggregate)
ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING3 rows before, current row, 3 rows after (centred window)

ROWS vs RANGE — Key Difference

-- ROWS: based on physical row position
SUM(amount) OVER (ORDER BY sale_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
-- Always sums exactly 3 rows: current + 2 before
-- RANGE: based on logical value proximity
SUM(amount) OVER (ORDER BY sale_date RANGE BETWEEN INTERVAL '2' DAY PRECEDING AND CURRENT ROW)
-- Sums all rows within a 2-day window before the current date
-- If multiple rows share the same date, all are included

Default frame when ORDER BY is specified: RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Default frame without ORDER BY: all rows in the partition (RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING).

When a query uses the same OVER definition multiple times, repeating it verbatim is error-prone and hard to maintain. The WINDOW clause (supported in PostgreSQL, MySQL 8+, BigQuery, and others) lets you define a window once and reference it by name.

-- Without WINDOW clause — repetitive and fragile
SELECT
name, department, salary,
SUM(salary) OVER (PARTITION BY department ORDER BY hire_date) AS running_sal,
AVG(salary) OVER (PARTITION BY department ORDER BY hire_date) AS running_avg,
COUNT(*) OVER (PARTITION BY department ORDER BY hire_date) AS running_cnt
FROM employees;
-- With WINDOW clause — define once, reuse freely
SELECT
name, department, salary,
SUM(salary) OVER dept_window AS running_sal,
AVG(salary) OVER dept_window AS running_avg,
COUNT(*) OVER dept_window AS running_cnt
FROM employees
WINDOW dept_window AS (
PARTITION BY department ORDER BY hire_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW

SELECT
year,
region,
total_sales,
LAG(total_sales) OVER (PARTITION BY region ORDER BY year) AS prev_year_sales,
ROUND(
(total_sales - LAG(total_sales) OVER (PARTITION BY region ORDER BY year))
* 100.0
/ NULLIF(LAG(total_sales) OVER (PARTITION BY region ORDER BY year), 0),
1
) AS yoy_growth_pct
FROM annual_sales
ORDER BY region, year;

SELECT
customer_id,
order_date,
order_amount,
SUM(order_amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS lifetime_value,
COUNT(*) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS order_number
FROM orders
ORDER BY customer_id, order_date;

-- Detect missing invoice numbers
SELECT
invoice_id,
LAG(invoice_id) OVER (ORDER BY invoice_id) AS prev_invoice,
invoice_id - LAG(invoice_id) OVER (ORDER BY invoice_id) - 1 AS gap_count
FROM invoices
HAVING gap_count > 0
ORDER BY invoice_id;

-- Define a new session when gap between events > 30 minutes
WITH flagged AS (
SELECT
user_id,
event_time,
CASE
WHEN event_time - LAG(event_time) OVER (
PARTITION BY user_id ORDER BY event_time
) > INTERVAL '30' MINUTE
THEN 1 ELSE 0
END AS new_session_flag
FROM clickstream
)
SELECT
user_id,
event_time,
SUM(new_session_flag) OVER (
PARTITION BY user_id ORDER BY event_time
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_number
FROM flagged
ORDER BY user_id, event_time;

Function / FeaturePostgreSQLSQL ServerMySQL 8+OracleBigQuery
ROW_NUMBER / RANK / DENSE_RANKYesYesYesYesYes
NTILEYesYesYesYesYes
SUM / AVG / COUNT OVERYesYesYesYesYes
LAG / LEADYesYesYesYesYes
FIRST_VALUE / LAST_VALUEYesYesYesYesYes
NTH_VALUEYesNoYesYesYes
PERCENT_RANK / CUME_DISTYesYesYesYesYes
WINDOW clause (named windows)YesNoYesNoYes
RANGE with intervalsYesNoYesYesYes

  1. Index the PARTITION BY and ORDER BY columns. Window functions perform a sort internally; an index on these columns avoids a full sort pass.
  2. Use CTEs to materialise intermediate results when the same window expression is referenced multiple times in complex queries.
  3. Avoid nesting window functions — SQL does not allow them. Use a CTE or subquery to layer window calculations.
  4. Minimise the partition size. Fewer rows per partition means smaller sort operations and less memory pressure.
  5. Use ROWS mode instead of RANGE when possible — ROWS is generally faster because it uses physical offsets, not value comparisons.
  6. On large tables, consider pre-filtering with a CTE before applying window functions to reduce the number of rows processed.
  7. In distributed engines (BigQuery, Spark SQL), keep PARTITION BY keys well-distributed to avoid data skew causing slow partitions.

MistakeProblemFix
Using window function in WHEREWindow functions are computed after WHERE; SQL raises an errorWrap in a CTE or subquery, then filter in the outer query
Forgetting frame with LAST_VALUEDefault frame is CURRENT ROW; LAST_VALUE returns current row’s valueAdd ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
Omitting ORDER BY in RANK()RANK without ORDER BY assigns rank 1 to all rowsAlways supply ORDER BY inside OVER for ranking functions
Confusing ROWS and RANGE with tiesRANGE includes all tied values; ROWS uses strict row countUse ROWS when you need exactly N rows; RANGE for value-based windows
Referencing a window function alias in HAVINGAliases from SELECT are not visible in HAVINGRepeat the window expression or use a subquery/CTE

GoalWindow Function Pattern
Unique row number per groupROW_NUMBER() OVER (PARTITION BY grp ORDER BY col)
Rank with gapsRANK() OVER (PARTITION BY grp ORDER BY col DESC)
Rank without gapsDENSE_RANK() OVER (PARTITION BY grp ORDER BY col DESC)
Top N per groupWHERE DENSE_RANK() … <= N  (in CTE)
Quartile / decile bucketsNTILE(4) OVER (ORDER BY col)
Running totalSUM(col) OVER (ORDER BY date ROWS UNBOUNDED PRECEDING)
Rolling N-row averageAVG(col) OVER (ORDER BY date ROWS N PRECEDING)
% of partition totalcol * 100.0 / SUM(col) OVER (PARTITION BY grp)
Compare to group averagecol – AVG(col) OVER (PARTITION BY grp)
Previous row valueLAG(col, 1) OVER (PARTITION BY grp ORDER BY date)
Next row valueLEAD(col, 1) OVER (PARTITION BY grp ORDER BY date)
Period-over-period changecol – LAG(col) OVER (ORDER BY date)
First value in groupFIRST_VALUE(col) OVER (PARTITION BY grp ORDER BY col)
Last value in groupLAST_VALUE(col) OVER (… ROWS UNBOUNDED FOLLOWING)
Percentile rank (0 to 1)PERCENT_RANK() OVER (ORDER BY col)
Cumulative distributionCUME_DIST() OVER (ORDER BY col)
Reuse window definitionWINDOW w AS (PARTITION BY grp ORDER BY col)

SQL window functions are a game changer for anyone working with analytical queries. They let you perform complex calculations — rankings, running totals, moving averages, period comparisons, sessionisation — in a single SQL statement that keeps every original row intact.

The key concepts to master are: the OVER clause with PARTITION BY and ORDER BY, the four ranking functions and when to use each, aggregate window functions for running and rolling calculations, LAG and LEAD for time-series comparisons, and the frame clause for precise control over which rows participate in each calculation.

Once window functions become second nature, you will find yourself reaching for GROUP BY far less often and writing queries that are simultaneously more powerful and more readable.

Happy Querying!


Discover more from DataSangyan

Subscribe to get the latest posts sent to your email.

Leave a Reply