1. Introduction
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.
2. SQL Window Functions vs GROUP BY
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.
| Feature | GROUP BY + Aggregate | Window Function |
| Rows in output | One row per group | All original rows preserved |
| Other columns | Must be in GROUP BY or aggregated | All columns freely selectable |
| Row context | No access to individual rows | Each row can see its peers |
| Use in WHERE | Use HAVING for aggregates | Cannot use in WHERE / HAVING directly |
| Ordering within group | Not possible | ORDER BY inside OVER() |
Side-by-Side Comparison
-- GROUP BY: collapses to 1 row per departmentSELECT department, AVG(salary) AS avg_salaryFROM employeesGROUP BY department;-- Window function: keeps ALL rows, adds avg per department alongsideSELECT employee_id, name, department, salary, AVG(salary) OVER (PARTITION BY department) AS dept_avg_salaryFROM 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.
3. Anatomy of the OVER Clause
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-clause | Required? | Purpose |
| PARTITION BY | No | Divides rows into partitions (like GROUP BY but without collapsing). Omit to treat the entire result as one partition. |
| ORDER BY | Sometimes | Defines the ordering within each partition. Required for ranking and offset functions; optional for aggregates. |
| frame clause | No | Restricts the physical rows the function considers. Defaults differ by function type (see Section 9). |
4. Ranking Functions
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.
| Function | Ties Handled | Gaps After Ties | Example Output (ties at 2nd) |
| ROW_NUMBER() | Arbitrary unique number | N/A | 1, 2, 3, 4 |
| RANK() | Same rank for ties | Yes | 1, 2, 2, 4 |
| DENSE_RANK() | Same rank for ties | No | 1, 2, 2, 3 |
| NTILE(n) | Divides into n equal buckets | N/A | 1, 1, 2, 2 |
4.1 ROW_NUMBER()
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_numFROM employees;-- Result: within each department, employees numbered 1,2,3... by salary desc-- Useful for: deduplication, pagination, top-N per group
4.2 RANK() and DENSE_RANK()
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_rnkFROM employees;-- salary: 95000, 90000, 90000, 80000-- RANK: 1, 2, 2, 4 (gap at 3)-- DENSE_RANK: 1, 2, 2, 3 (no gap)
4.3 NTILE(n) — Bucketing into Quantiles
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 departmentSELECT name, department, salary, NTILE(4) OVER (PARTITION BY department ORDER BY salary DESC) AS quartileFROM employees;-- quartile = 1 -> top 25%, quartile = 4 -> bottom 25%
4.4 Practical Use Case — Top N Per Group
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, salaryFROM rankedWHERE dr <= 3ORDER BY department, salary DESC;
5. Aggregate Window Functions
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.
5.1 Running Total (Cumulative SUM)
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_totalFROM ordersORDER BY customer_id, order_date;-- Each row shows the cumulative spend for that customer up to that order
5.2 Moving Average (Rolling AVG)
-- 7-day rolling average of daily salesSELECT sale_date, daily_revenue, AVG(daily_revenue) OVER ( ORDER BY sale_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ) AS rolling_7day_avgFROM daily_salesORDER BY sale_date;
5.3 Percentage of Total
-- Each product's revenue as % of its category totalSELECT product_name, category, revenue, ROUND( revenue * 100.0 / SUM(revenue) OVER (PARTITION BY category), 2 ) AS pct_of_categoryFROM product_salesORDER BY category, revenue DESC;
5.4 Compare Row to Group Aggregate
-- Employee salary vs. department average and overall company averageSELECT 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_avgFROM employeesORDER BY department, salary DESC;
6. Offset Functions — LAG and LEAD
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.
| Function | Returns | Default for Missing Rows |
| LAG(col, n, default) | Value from n rows BEFORE current row | NULL (or specified default) |
| LEAD(col, n, default) | Value from n rows AFTER current row | NULL (or specified default) |
6.1 Month-over-Month Revenue Change
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_changeFROM monthly_revenueORDER BY month;
6.2 Detecting Consecutive Events
SELECT user_id, session_id, page_visited, LAG(page_visited) OVER (PARTITION BY user_id ORDER BY visited_at) AS prev_pageFROM page_viewsHAVING page_visited = prev_page -- consecutive same-page visitsORDER BY user_id, visited_at;
6.3 LEAD — Days Until Next Order
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_orderFROM ordersORDER BY customer_id, order_date;
7. Value Functions — FIRST_VALUE, LAST_VALUE, NTH_VALUE
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.
7.1 FIRST_VALUE and LAST_VALUE
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_salaryFROM 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.
7.2 NTH_VALUE — Pick Any Row in the Window
-- Second highest salary per departmentSELECT 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_salaryFROM employees;
8. Distribution Functions — PERCENT_RANK and CUME_DIST
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.
| Function | Formula | Range | Interpretation |
| PERCENT_RANK() | (rank – 1) / (total_rows – 1) | 0.0 to 1.0 | Percentage of rows ranked BELOW the current row |
| CUME_DIST() | rank / total_rows | 1/n to 1.0 | Percentage 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_distFROM employeesORDER 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”).
9. Frame Specification — ROWS vs RANGE
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 Clause | Meaning |
| ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | All rows from start of partition to current row (running total) |
| ROWS BETWEEN 2 PRECEDING AND CURRENT ROW | Current row and 2 rows before it (rolling 3-row window) |
| ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING | Current row to end of partition |
| ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | All rows in the partition (global aggregate) |
| ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING | 3 rows before, current row, 3 rows after (centred window) |
ROWS vs RANGE — Key Difference
-- ROWS: based on physical row positionSUM(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 proximitySUM(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).
10. Named Windows — The WINDOW Clause
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 fragileSELECT 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_cntFROM employees;-- With WINDOW clause — define once, reuse freelySELECT 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_cntFROM employeesWINDOW dept_window AS ( PARTITION BY department ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
11. Real-World Examples
11.1 Year-over-Year Sales Growth
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_pctFROM annual_salesORDER BY region, year;
11.2 Customer Lifetime Value Running Total
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_numberFROM ordersORDER BY customer_id, order_date;
11.3 Identify Gaps in Sequential Data
-- Detect missing invoice numbersSELECT 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_countFROM invoicesHAVING gap_count > 0ORDER BY invoice_id;
11.4 Sessionisation — Group Clicks into Sessions
-- Define a new session when gap between events > 30 minutesWITH 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_numberFROM flaggedORDER BY user_id, event_time;
12. Database Compatibility
| Function / Feature | PostgreSQL | SQL Server | MySQL 8+ | Oracle | BigQuery |
| ROW_NUMBER / RANK / DENSE_RANK | Yes | Yes | Yes | Yes | Yes |
| NTILE | Yes | Yes | Yes | Yes | Yes |
| SUM / AVG / COUNT OVER | Yes | Yes | Yes | Yes | Yes |
| LAG / LEAD | Yes | Yes | Yes | Yes | Yes |
| FIRST_VALUE / LAST_VALUE | Yes | Yes | Yes | Yes | Yes |
| NTH_VALUE | Yes | No | Yes | Yes | Yes |
| PERCENT_RANK / CUME_DIST | Yes | Yes | Yes | Yes | Yes |
| WINDOW clause (named windows) | Yes | No | Yes | No | Yes |
| RANGE with intervals | Yes | No | Yes | Yes | Yes |
13. Performance Tips
- Index the PARTITION BY and ORDER BY columns. Window functions perform a sort internally; an index on these columns avoids a full sort pass.
- Use CTEs to materialise intermediate results when the same window expression is referenced multiple times in complex queries.
- Avoid nesting window functions — SQL does not allow them. Use a CTE or subquery to layer window calculations.
- Minimise the partition size. Fewer rows per partition means smaller sort operations and less memory pressure.
- Use ROWS mode instead of RANGE when possible — ROWS is generally faster because it uses physical offsets, not value comparisons.
- On large tables, consider pre-filtering with a CTE before applying window functions to reduce the number of rows processed.
- In distributed engines (BigQuery, Spark SQL), keep PARTITION BY keys well-distributed to avoid data skew causing slow partitions.
14. Common Mistakes & How to Fix Them
| Mistake | Problem | Fix |
| Using window function in WHERE | Window functions are computed after WHERE; SQL raises an error | Wrap in a CTE or subquery, then filter in the outer query |
| Forgetting frame with LAST_VALUE | Default frame is CURRENT ROW; LAST_VALUE returns current row’s value | Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING |
| Omitting ORDER BY in RANK() | RANK without ORDER BY assigns rank 1 to all rows | Always supply ORDER BY inside OVER for ranking functions |
| Confusing ROWS and RANGE with ties | RANGE includes all tied values; ROWS uses strict row count | Use ROWS when you need exactly N rows; RANGE for value-based windows |
| Referencing a window function alias in HAVING | Aliases from SELECT are not visible in HAVING | Repeat the window expression or use a subquery/CTE |
15. Quick Reference Cheat Sheet
| Goal | Window Function Pattern |
| Unique row number per group | ROW_NUMBER() OVER (PARTITION BY grp ORDER BY col) |
| Rank with gaps | RANK() OVER (PARTITION BY grp ORDER BY col DESC) |
| Rank without gaps | DENSE_RANK() OVER (PARTITION BY grp ORDER BY col DESC) |
| Top N per group | WHERE DENSE_RANK() … <= N (in CTE) |
| Quartile / decile buckets | NTILE(4) OVER (ORDER BY col) |
| Running total | SUM(col) OVER (ORDER BY date ROWS UNBOUNDED PRECEDING) |
| Rolling N-row average | AVG(col) OVER (ORDER BY date ROWS N PRECEDING) |
| % of partition total | col * 100.0 / SUM(col) OVER (PARTITION BY grp) |
| Compare to group average | col – AVG(col) OVER (PARTITION BY grp) |
| Previous row value | LAG(col, 1) OVER (PARTITION BY grp ORDER BY date) |
| Next row value | LEAD(col, 1) OVER (PARTITION BY grp ORDER BY date) |
| Period-over-period change | col – LAG(col) OVER (ORDER BY date) |
| First value in group | FIRST_VALUE(col) OVER (PARTITION BY grp ORDER BY col) |
| Last value in group | LAST_VALUE(col) OVER (… ROWS UNBOUNDED FOLLOWING) |
| Percentile rank (0 to 1) | PERCENT_RANK() OVER (ORDER BY col) |
| Cumulative distribution | CUME_DIST() OVER (ORDER BY col) |
| Reuse window definition | WINDOW w AS (PARTITION BY grp ORDER BY col) |
16. Conclusion
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.