Tutorial

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.

Anuj SainiMar 11, 2026Updated Aug 24, 202614 min read

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_idnamedepartmentsalary
101SarahEngineering₹12,00,000
102AlexEngineering₹9,00,000
103RohanEngineering₹15,00,000
104PriyaMarketing₹8,00,000
105DavidMarketing₹10,00,000
106AnanyaMarketing₹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:

sql
SELECT
  department,
  AVG(salary) AS avg_salary
FROM employees
GROUP BY department;

Result of GROUP BY:

departmentavg_salary
Engineering₹12,00,000
Marketing₹8,00,000

GROUP BY succeeded in calculating the averages, but look at what happened to the table:

  1. The original 6 rows were collapsed into 2 rows.
  2. Individual details were destroyed: You lost Sarah, Alex, Rohan, Priya, David, and Ananya.
  3. 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:

sql
-- 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:

sql
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:

namedepartmentsalarydept_avg_salarydiff_from_avg
SarahEngineering₹12,00,000₹12,00,000₹0
AlexEngineering₹9,00,000₹12,00,000-₹3,00,000
RohanEngineering₹15,00,000₹12,00,000+₹3,00,000
PriyaMarketing₹8,00,000₹8,00,000₹0
DavidMarketing₹10,00,000₹8,00,000+₹2,00,000
AnanyaMarketing₹6,00,000₹8,00,000-₹2,00,000

Notice the Magic:

  • All 6 original rows are preserved.
  • The dept_avg_salary column is calculated specifically for each department.
  • We calculated diff_from_avg on 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

Window 1: Engineering
AVG(12L, 9L, 15L) = ₹12,00,000
namedepartmentsalary dept_avg_salary
SarahEngineering₹12,00,000₹12,00,000
AlexEngineering₹9,00,000₹12,00,000
RohanEngineering₹15,00,000₹12,00,000
Window 2: Marketing
AVG(8L, 10L, 6L) = ₹8,00,000
namedepartmentsalary dept_avg_salary
PriyaMarketing₹8,00,000₹8,00,000
DavidMarketing₹10,00,000₹8,00,000
AnanyaMarketing₹6,00,000₹8,00,000
💡 Key Takeaway: `PARTITION BY` calculates the aggregate independently within each colored box, but attaches the calculated value to every single employee row without discarding any records.

GROUP BY vs Window Functions: Direct Comparison

FeatureGROUP BYWindow Function (OVER)
Row CountCollapses rows (1 row per group)Preserves all individual rows
Row IdentityDestroys individual columns (id, name)Retains all original columns
CalculationsAggregates only (SUM, AVG, COUNT)Aggregates, Rankings (RANK), Offsets (LAG/LEAD)
ComplexityRequires self-joins for row-level comparisonsSingle 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.

sql
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 ComponentWindow Scope & BehaviorExample Business Use
Empty OVER()Window spans the entire datasetShow global company average on every row
PARTITION BYSlices the table into departmental/group windowsCalculate department metrics without collapsing
ORDER BYSequences rows inside each window sliceCalculate rankings (RANK) and running totals
ROWS BETWEENDefines a sliding frame relative to current row7-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:

sql
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:

sql
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:

sql
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:

FamilyKey FunctionsPrimary PurposeCommon Industry Use Case
1. RankingROW_NUMBER(), RANK(), DENSE_RANK()Assign position / rank to rowsDeduplication, top-N per group
2. Value & OffsetLAG(), LEAD(), FIRST_VALUE(), LAST_VALUE()Fetch values from other rowsPeriod-over-period delta, churn
3. AggregatesSUM() OVER, AVG() OVER, COUNT() OVERCalculate cumulative/rolling aggregatesRunning revenue totals, moving avg
4. DistributionNTILE(), CUME_DIST(), PERCENT_RANK()Divide rows into percentiles/bucketsCustomer 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:

studentscore
Aman100
Priya90
Rahul90
Sneha80
Vikram70

Let's run all three ranking functions simultaneously:

sql
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:

studentscoreROW_NUMBERRANKDENSE_RANK
Aman100111
Priya90222
Rahul90322
Sneha8044 (Skipped 3!)3 (No gaps)
Vikram70554

The Golden Rule of Ranking:

  1. ROW_NUMBER(): Always strictly sequential (1, 2, 3, 4, 5). Never produces ties. Use this for pagination or deduplicating records.
  2. 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).
  3. 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

sql
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 from offset rows before the current row.
  • LEAD(column, offset, default): Fetches a value from offset rows after the current row.

Sample Table: monthly_revenue

monthrevenue
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

sql
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:

monthrevenueprev_month_revenuemom_dollar_changemom_growth_pct
2026-01₹10,00,000NULLNULLNULL
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:

sql
SELECT
  month,
  revenue,
  SUM(revenue) OVER (ORDER BY month) AS cumulative_revenue
FROM monthly_revenue;

Result:

monthrevenuecumulative_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

monthrevenueActive Window Frame Scopecumulative_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:

sql
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:

sql
-- ❌ 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):

StepClauseWhat Happens During Execution
1FROM & JOINTables are loaded, merged, and cross-referenced
2WHEREIndividual rows are filtered (Window functions do NOT exist yet!)
3GROUP BYRows are collapsed into summary groups
4HAVINGGrouped aggregate results are filtered
5SELECT & WindowsWindow functions are computed here!
6DISTINCTDuplicate rows are removed
7ORDER BYFinal result set is sorted
8LIMIT / OFFSETFinal 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:

sql
-- ✅ 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

FunctionTypeBehavior with TiesCommon Industry Use Case
ROW_NUMBER()RankingNo ties (1, 2, 3, 4)Deduplicating records, pagination
RANK()RankingTies allowed, skips ranks (1, 2, 2, 4)Leaderboards, competitions
DENSE_RANK()RankingTies allowed, no gaps (1, 2, 2, 3)Top-N salaries, Nth highest price
LAG(col, n)ValueFetches N rows priorMonth-over-month growth, session gaps
LEAD(col, n)ValueFetches N rows aheadNext purchase time, churn prediction
SUM() OVERAggregateCumulative sumRunning totals, cash flow pacing
AVG() OVERAggregateRolling mean7-day or 30-day moving average
NTILE(n)DistributionSplits into N equal bucketsQuartile 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 Free

Frequently 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.

Anuj Saini

Written by

Anuj SainiFounder & Lead Instructor

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.