SQL Window Functions: Complete Guide with Examples (2026)
Master SQL window functions from complete basics. Understand why GROUP BY collapses rows, how OVER() preserves row details, and how to use ROW_NUMBER, RANK, LAG, LEAD, and running totals with real sample tables.
Window functions are arguably the most tested SQL concept in technical interviews for Data Analysts, Analytics Engineers, and Data Scientists.
Most tutorials jump straight into complex syntax like OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN ...) without explaining the fundamental reason why window functions exist.
In this guide, we will build your understanding from the absolute ground up: starting with the limitations of GROUP BY, seeing why row collapsing causes problems, and mastering every major window function using concrete sample tables. For chaining window functions with named steps, see our SQL CTE Guide.
1. The Fundamental Problem: Why GROUP BY Isn't Enough
To understand window functions, you must first understand the collapse trap of GROUP BY.
Imagine we have a company database with an employees table:
Sample Table: employees
| emp_id | name | department | salary |
|---|---|---|---|
| 101 | Sarah | Engineering | ₹12,00,000 |
| 102 | Alex | Engineering | ₹9,00,000 |
| 103 | Rohan | Engineering | ₹15,00,000 |
| 104 | Priya | Marketing | ₹8,00,000 |
| 105 | David | Marketing | ₹10,00,000 |
| 106 | Ananya | Marketing | ₹6,00,000 |
What Happens When You Use GROUP BY
Suppose your manager asks: "What is the average salary in each department?"
You write a standard GROUP BY query:
SELECT
department,
AVG(salary) AS avg_salary
FROM employees
GROUP BY department;Result of GROUP BY:
| department | avg_salary |
|---|---|
| Engineering | ₹12,00,000 |
| Marketing | ₹8,00,000 |
GROUP BY succeeded in calculating the averages, but look at what happened to the table:
- The original 6 rows were collapsed into 2 rows.
- Individual details were destroyed: You lost Sarah, Alex, Rohan, Priya, David, and Ananya.
- Individual salaries were erased: You can no longer see what Alex makes.
The Business Question That Breaks GROUP BY
Now your manager asks a slightly deeper business question:
"Show me every employee with their name and salary, alongside their department's average salary, and calculate how much above or below the department average they are."
With basic GROUP BY, you cannot do this in a single query. Because GROUP BY collapses the rows, you can't display individual employee names alongside group aggregates without writing messy subqueries or self-joins:
-- The messy, slow way WITHOUT window functions (Self-Join):
SELECT
e.name,
e.department,
e.salary,
d.avg_salary,
(e.salary - d.avg_salary) AS diff_from_avg
FROM employees e
JOIN (
SELECT department, AVG(salary) AS avg_salary
FROM employees
GROUP BY department
) d ON e.department = d.department;This query requires scanning the table twice, creating an intermediate subquery, and joining it back to the original table. It is verbose, slow on large datasets, and error-prone.
2. The Window Function Solution: Aggregate Without Collapsing
This is the exact reason Window Functions were invented.
A window function looks at a group of rows (a "window") to calculate an aggregate or rank, but keeps every individual row intact in the final output.
Here is how you solve the exact same business question using a window function:
SELECT
name,
department,
salary,
AVG(salary) OVER (PARTITION BY department) AS dept_avg_salary,
salary - AVG(salary) OVER (PARTITION BY department) AS diff_from_avg
FROM employees;Output:
| name | department | salary | dept_avg_salary | diff_from_avg |
|---|---|---|---|---|
| Sarah | Engineering | ₹12,00,000 | ₹12,00,000 | ₹0 |
| Alex | Engineering | ₹9,00,000 | ₹12,00,000 | -₹3,00,000 |
| Rohan | Engineering | ₹15,00,000 | ₹12,00,000 | +₹3,00,000 |
| Priya | Marketing | ₹8,00,000 | ₹8,00,000 | ₹0 |
| David | Marketing | ₹10,00,000 | ₹8,00,000 | +₹2,00,000 |
| Ananya | Marketing | ₹6,00,000 | ₹8,00,000 | -₹2,00,000 |
Notice the Magic:
- All 6 original rows are preserved.
- The
dept_avg_salarycolumn is calculated specifically for each department. - We calculated
diff_from_avgon the fly in a single scan without any joins or subqueries!
Visualizing PARTITION BY Windows
How SQL slices the table into independent calculation frames without collapsing rows
| name | department | salary | dept_avg_salary |
|---|---|---|---|
| Sarah | Engineering | ₹12,00,000 | ₹12,00,000 |
| Alex | Engineering | ₹9,00,000 | ₹12,00,000 |
| Rohan | Engineering | ₹15,00,000 | ₹12,00,000 |
| name | department | salary | dept_avg_salary |
|---|---|---|---|
| Priya | Marketing | ₹8,00,000 | ₹8,00,000 |
| David | Marketing | ₹10,00,000 | ₹8,00,000 |
| Ananya | Marketing | ₹6,00,000 | ₹8,00,000 |
GROUP BY vs Window Functions: Direct Comparison
| Feature | GROUP BY | Window Function (OVER) |
|---|---|---|
| Row Count | Collapses rows (1 row per group) | Preserves all individual rows |
| Row Identity | Destroys individual columns (id, name) | Retains all original columns |
| Calculations | Aggregates only (SUM, AVG, COUNT) | Aggregates, Rankings (RANK), Offsets (LAG/LEAD) |
| Complexity | Requires self-joins for row-level comparisons | Single pass over data with OVER() |
3. Deconstructing the OVER() Clause
The OVER() clause is what turns a regular SQL function into a window function. It defines the boundaries of the window the function looks at.
FUNCTION() OVER (
PARTITION BY column1
ORDER BY column2
ROWS BETWEEN frame_start AND frame_end
)Let's break down each part of the OVER() clause:
| Clause Component | Window Scope & Behavior | Example Business Use |
|---|---|---|
Empty OVER() | Window spans the entire dataset | Show global company average on every row |
PARTITION BY | Slices the table into departmental/group windows | Calculate department metrics without collapsing |
ORDER BY | Sequences rows inside each window slice | Calculate rankings (RANK) and running totals |
ROWS BETWEEN | Defines a sliding frame relative to current row | 7-day moving averages or rolling metrics |
Step 1: Empty OVER() (Whole Table Window)
If you provide an empty OVER(), the window is the entire dataset:
SELECT
name,
salary,
AVG(salary) OVER () AS company_avg_salary
FROM employees;Result: Every employee row receives the exact same overall company average salary (₹10,00,000).
Step 2: PARTITION BY (Group Slices)
PARTITION BY divides the rows into logical groups (like GROUP BY), but calculates the value per group without combining rows:
SELECT
name,
department,
salary,
MAX(salary) OVER (PARTITION BY department) AS highest_dept_salary
FROM employees;Result: Sarah, Alex, and Rohan all see Engineering's maximum (₹15,00,000), while Priya, David, and Ananya see Marketing's maximum (₹10,00,000).
Step 3: ORDER BY (Window Ordering & Cumulative Frame)
When you add ORDER BY inside OVER(), it tells SQL to process the window in a specific sequence. This enables rankings and running totals:
SELECT
name,
department,
salary,
SUM(salary) OVER (PARTITION BY department ORDER BY salary ASC) AS running_dept_payroll
FROM employees;4. The 4 Families of Window Functions
SQL window functions fall into 4 main categories:
| Family | Key Functions | Primary Purpose | Common Industry Use Case |
|---|---|---|---|
| 1. Ranking | ROW_NUMBER(), RANK(), DENSE_RANK() | Assign position / rank to rows | Deduplication, top-N per group |
| 2. Value & Offset | LAG(), LEAD(), FIRST_VALUE(), LAST_VALUE() | Fetch values from other rows | Period-over-period delta, churn |
| 3. Aggregates | SUM() OVER, AVG() OVER, COUNT() OVER | Calculate cumulative/rolling aggregates | Running revenue totals, moving avg |
| 4. Distribution | NTILE(), CUME_DIST(), PERCENT_RANK() | Divide rows into percentiles/buckets | Customer spending quartiles |
Let's explore each family with real examples.
5. Ranking Functions: ROW_NUMBER vs RANK vs DENSE_RANK
The most common interview question asks candidates to rank items (e.g., "Find the top 3 products per category" or "Find the 2nd highest salary").
All three functions assign ranks, but they treat tied values very differently:
Sample Dataset with Ties:
Suppose we have an exam scores table:
| student | score |
|---|---|
| Aman | 100 |
| Priya | 90 |
| Rahul | 90 |
| Sneha | 80 |
| Vikram | 70 |
Let's run all three ranking functions simultaneously:
SELECT
student,
score,
ROW_NUMBER() OVER (ORDER BY score DESC) AS row_num,
RANK() OVER (ORDER BY score DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rnk
FROM scores;Side-by-Side Result:
| student | score | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|---|
| Aman | 100 | 1 | 1 | 1 |
| Priya | 90 | 2 | 2 | 2 |
| Rahul | 90 | 3 | 2 | 2 |
| Sneha | 80 | 4 | 4 (Skipped 3!) | 3 (No gaps) |
| Vikram | 70 | 5 | 5 | 4 |
The Golden Rule of Ranking:
ROW_NUMBER(): Always strictly sequential (1, 2, 3, 4, 5). Never produces ties. Use this for pagination or deduplicating records.RANK(): Assigns tied rows the same rank, but skips numbers to account for the tie (1, 2, 2, 4). Use this for sports leaderboards (e.g., two people get silver medals, no one gets bronze).DENSE_RANK(): Assigns tied rows the same rank, with no gaps (1, 2, 2, 3). Use this for "Find the Nth highest value" questions where tied 1st place shouldn't skip 2nd place!
Real Interview Scenario: Finding the 2nd Highest Salary Per Department
WITH ranked_salaries AS (
SELECT
name,
department,
salary,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) as salary_rank
FROM employees
)
SELECT name, department, salary
FROM ranked_salaries
WHERE salary_rank = 2;Why DENSE_RANK is mandatory here
If two employees in Engineering tie for the top salary of ₹15,00,000, RANK() would assign them both rank 1 and assign the next person rank 3. Filtering by WHERE salary_rank = 2 would return 0 rows! DENSE_RANK() guarantees rank 2 exists.
6. Value & Navigation Functions: LAG() and LEAD()
LAG() and LEAD() allow you to look at previous or subsequent rows without performing self-joins. This is essential for period-over-period growth, session interval analysis, and churn detection.
LAG(column, offset, default): Fetches a value fromoffsetrows before the current row.LEAD(column, offset, default): Fetches a value fromoffsetrows after the current row.
Sample Table: monthly_revenue
| month | revenue |
|---|---|
| 2026-01 | ₹10,00,000 |
| 2026-02 | ₹12,50,000 |
| 2026-03 | ₹11,00,000 |
| 2026-04 | ₹15,00,000 |
Calculating Month-Over-Month (MoM) Growth Percentage
SELECT
month,
revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY month) AS mom_dollar_change,
ROUND(
(revenue - LAG(revenue, 1) OVER (ORDER BY month)) * 100.0
/ NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
2
) AS mom_growth_pct
FROM monthly_revenue;Result:
| month | revenue | prev_month_revenue | mom_dollar_change | mom_growth_pct |
|---|---|---|---|---|
| 2026-01 | ₹10,00,000 | NULL | NULL | NULL |
| 2026-02 | ₹12,50,000 | ₹10,00,000 | +₹2,50,000 | +25.00% |
| 2026-03 | ₹11,00,000 | ₹12,50,000 | -₹1,50,000 | -12.00% |
| 2026-04 | ₹15,00,000 | ₹11,00,000 | +₹4,00,000 | +36.36% |
Production Pro-Tip: NULLIF
Always wrap the divisor in NULLIF(..., 0). If a prior month had 0 revenue, NULLIF converts the 0 to NULL, preventing a query-crashing division by zero error.
7. Aggregate Window Functions: Running Totals & Moving Averages
1. Cumulative Running Total
To calculate a cumulative total that sums numbers up to the current row:
SELECT
month,
revenue,
SUM(revenue) OVER (ORDER BY month) AS cumulative_revenue
FROM monthly_revenue;Result:
| month | revenue | cumulative_revenue |
|---|---|---|
| 2026-01 | ₹10,00,000 | ₹10,00,000 |
| 2026-02 | ₹12,50,000 | ₹22,50,000 |
| 2026-03 | ₹11,00,000 | ₹33,50,000 |
| 2026-04 | ₹15,00,000 | ₹48,50,000 |
Interactive: How the Window Slides Row-by-Row
Watch how `SUM(revenue) OVER (ORDER BY month)` expands its window frame as it processes each row
| month | revenue | Active Window Frame Scope | cumulative_revenue |
|---|---|---|---|
| 2026-01 | ₹10,00,000 | 👉 Current Row (Accumulator) | ₹10,00,000 |
| 2026-02 | ₹12,50,000 | — Not yet reached | — |
| 2026-03 | ₹11,00,000 | — Not yet reached | — |
| 2026-04 | ₹15,00,000 | — Not yet reached | — |
Row 1: The window starts at Row 1. Cumulative Sum = ₹10L.
Calculation: ₹10,00,000 = ₹10,00,000
2. 7-Day Rolling Moving Average
Moving averages smooth out erratic day-to-day spikes to reveal true underlying trends. We specify the sliding frame using ROWS BETWEEN:
SELECT
date,
daily_active_users,
ROUND(
AVG(daily_active_users) OVER (
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
),
0
) AS moving_avg_7d
FROM daily_metrics;How the frame works: 6 PRECEDING AND CURRENT ROW takes the current day plus the 6 previous days = 7 days total.
8. The Critical Trap: Why You Can't Use Window Functions in WHERE
Every SQL practitioner eventually writes this query and gets greeted with an error:
-- ❌ THIS WILL FAIL WITH A SYNTAX ERROR:
SELECT *
FROM orders
WHERE ROW_NUMBER() OVER (ORDER BY order_date DESC) = 1;Error:
Window functions are not allowed in WHERE
Why Does This Happen? (SQL Order of Execution)
SQL clauses do not execute in the order they are written (SELECT appears first in query text, but executes near the very end):
| Step | Clause | What Happens During Execution |
|---|---|---|
| 1 | FROM & JOIN | Tables are loaded, merged, and cross-referenced |
| 2 | WHERE | Individual rows are filtered (Window functions do NOT exist yet!) |
| 3 | GROUP BY | Rows are collapsed into summary groups |
| 4 | HAVING | Grouped aggregate results are filtered |
| 5 | SELECT & Windows | Window functions are computed here! |
| 6 | DISTINCT | Duplicate rows are removed |
| 7 | ORDER BY | Final result set is sorted |
| 8 | LIMIT / OFFSET | Final pagination slice is returned |
Because WHERE executes at Step 2, window functions (computed in Step 5) have not been evaluated yet!
The Fix: Wrap in a CTE or Subquery
To filter on a window function, you compute it inside a Common Table Expression (WITH clause) or subquery, then filter the resulting table in the outer query:
-- ✅ THE CORRECT WAY:
WITH ranked_orders AS (
SELECT
order_id,
customer_id,
order_date,
total_amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY order_date DESC
) AS rn
FROM orders
)
SELECT order_id, customer_id, order_date, total_amount
FROM ranked_orders
WHERE rn = 1;9. Quick Reference Cheatsheet
| Function | Type | Behavior with Ties | Common Industry Use Case |
|---|---|---|---|
ROW_NUMBER() | Ranking | No ties (1, 2, 3, 4) | Deduplicating records, pagination |
RANK() | Ranking | Ties allowed, skips ranks (1, 2, 2, 4) | Leaderboards, competitions |
DENSE_RANK() | Ranking | Ties allowed, no gaps (1, 2, 2, 3) | Top-N salaries, Nth highest price |
LAG(col, n) | Value | Fetches N rows prior | Month-over-month growth, session gaps |
LEAD(col, n) | Value | Fetches N rows ahead | Next purchase time, churn prediction |
SUM() OVER | Aggregate | Cumulative sum | Running totals, cash flow pacing |
AVG() OVER | Aggregate | Rolling mean | 7-day or 30-day moving average |
NTILE(n) | Distribution | Splits into N equal buckets | Quartile analysis, user segmenting |
10. Practice These on Real Databases
Understanding window functions conceptually is the first step. Writing them fluently under interview conditions requires hands-on execution.
Practice Window Functions on Real Databases
Topfolio offers 190+ interactive SQL practice questions. Write real queries against live PostgreSQL databases with instant grading and test cases.
Start Practicing FreeFrequently Asked Questions
Why do we need window functions instead of GROUP BY?
GROUP BY collapses multiple rows into a single summary row per group, destroying individual row details like employee names or transaction IDs. Window functions perform calculations across a group of rows while keeping every original row intact in the final output.
Can I use a window function in a WHERE clause?
No. In SQL order of execution, the WHERE clause runs before window functions are calculated in the SELECT phase. To filter by a window function result (like ROW_NUMBER() = 1), you must wrap the query in a Common Table Expression (CTE) or subquery.
What is the difference between RANK() and DENSE_RANK()?
Both assign identical ranks to tied values. However, RANK() skips rank numbers after a tie (e.g., 1, 2, 2, 4), whereas DENSE_RANK() does not skip numbers (e.g., 1, 2, 2, 3).
What does an empty OVER() clause do in SQL?
An empty OVER() clause treats the entire dataset as a single window frame. For example, AVG(salary) OVER () calculates the overall average across all rows and attaches that same number to every individual row.
How do LAG() and LEAD() calculate month-over-month growth?
LAG() accesses data from a previous row at a specified physical offset within the window frame. By partitioning by customer/product and ordering by month, LAG(revenue, 1) retrieves the previous month's revenue to compute period-over-period percentage growth without self-joins.

Written by
Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.
Related Articles
ROW_NUMBER vs RANK vs DENSE_RANK in SQL (Tie Examples)
Understand the exact difference between ROW_NUMBER(), RANK(), and DENSE_RANK() in SQL. See how ties are handled (1,2,3 vs 1,2,2,4 vs 1,2,2,3) with interactive queries.
SQL RANK Function: RANK vs DENSE_RANK vs ROW_NUMBER Guide
Master the SQL RANK function with side-by-side comparisons of RANK, DENSE_RANK, and ROW_NUMBER, PARTITION BY logic, and Top-N group queries.
Full Outer Join In Sql: 2026 Guide & Examples
Master the full outer join in sql with practical examples, billing reconciliation queries, syntax rules, and NULL handling for data analysts.