MySQL Window Functions - Complete Guide
MySQL Window Functions are extremely useful for SQL developers, data analysts, and backend developers. They allow you to perform calculations across multiple rows while keeping each individual row in the result set.
They are commonly used for:
- Finding the highest-paid employees in each department.
- Ranking products, employees, or customers.
- Calculating running totals and cumulative sums.
- Comparing current and previous records.
- Finding the difference between consecutive transactions.
- Calculating moving averages.
- Finding the first, last, or Nth value in a group.
- Calculating percentages and distributions.
MySQL supports window functions starting with MySQL 8.0. The examples below use MySQL 8.x syntax.
Let’s understand them step by step.
1. What is a window function?
First, understand the difference between GROUP BY and a window function.
Suppose we have an employees table:
employees
| emp_id | emp_name | department | salary |
|---|---|---|---|
| 1 | John | IT | 5000 |
| 2 | Alice | IT | 7000 |
| 3 | Bob | IT | 6000 |
| 4 | David | HR | 4000 |
| 5 | Emma | HR | 5000 |
| 6 | Mike | HR | 6000 |
Example A: Using GROUP BY
SELECT
department,
AVG(salary) AS avg_salary
FROM employees
GROUP BY department;
Result:
| department | avg_salary |
|---|---|
| IT | 6000 |
| HR | 5000 |
Notice that the individual employee records disappear. You get one row per department.
Example B: Using a window function
SELECT
emp_id,
emp_name,
department,
salary,
AVG(salary) OVER (
PARTITION BY department
) AS avg_salary
FROM employees;
Result:
| emp_name | department | salary | avg_salary |
|---|---|---|---|
| John | IT | 5000 | 6000 |
| Alice | IT | 7000 | 6000 |
| Bob | IT | 6000 | 6000 |
| David | HR | 4000 | 5000 |
| Emma | HR | 5000 | 5000 |
| Mike | HR | 6000 | 5000 |
The important difference: A window function calculates values across related rows without combining those rows into one result.
2. Understanding the OVER() clause
The OVER() clause is the most important concept in window functions.
General syntax:
FUNCTION_NAME(expression)
OVER (
PARTITION BY column_name
ORDER BY column_name
ROWS BETWEEN ...
)
There are three important components.
A. PARTITION BY
Divides rows into separate groups.
SELECT
emp_name,
department,
salary,
SUM(salary) OVER (
PARTITION BY department
) AS department_total
FROM employees;
Explanation:
- IT employees form one partition.
- HR employees form another partition.
- The salary total is calculated independently for each department.
- All employee records remain in the output.
If you omit PARTITION BY, MySQL considers all rows as one partition.
B. ORDER BY
Determines the order in which rows are processed inside each partition.
SELECT
emp_name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS row_num
FROM employees;
Employees are numbered by salary, from highest to lowest, within each department.
C. ROWS BETWEEN
Defines which rows are included in the calculation for the current row.
For example:
SUM(salary) OVER (
ORDER BY emp_id
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
)
This calculates a running total from the first row up to the current row.
We will explore frames in more detail later.
3. All MySQL Window Functions
MySQL provides 11 dedicated non-aggregate window functions.
They can be divided into four categories:
| Category | Functions |
|---|---|
| Ranking | ROW_NUMBER, RANK, DENSE_RANK |
| Distribution | PERCENT_RANK, CUME_DIST, NTILE |
| Navigation | LAG, LEAD |
| Value | FIRST_VALUE, LAST_VALUE, NTH_VALUE |
Additionally, several aggregate functions can be used as window functions.
Let’s understand each one.
Category 1: Ranking Functions
1. ROW_NUMBER()
Purpose: Assigns a unique sequential number to every row within a partition.
Syntax:
ROW_NUMBER() OVER (
PARTITION BY column_name
ORDER BY column_name
)
Example:
SELECT
emp_name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS row_num
FROM employees;
Expected result:
| emp_name | department | salary | row_num |
|---|---|---|---|
| Alice | IT | 7000 | 1 |
| Bob | IT | 6000 | 2 |
| John | IT | 5000 | 3 |
| Mike | HR | 6000 | 1 |
| Emma | HR | 5000 | 2 |
| David | HR | 4000 | 3 |
How does it work?
PARTITION BY departmentdivides employees into departments.ORDER BY salary DESCsorts employees by salary.ROW_NUMBER()assigns a unique number starting from 1 in each department.
Even if two employees have the same salary, they receive different row numbers.
Real-world use cases:
- Finding the latest transaction for each customer.
- Removing duplicate records.
- Finding the first employee in each department.
- Selecting the top N records in each group.
Example: Find the highest-paid employee in each department.
SELECT *
FROM (
SELECT
emp_name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC, emp_id
) AS rn
FROM employees
) AS ranked
WHERE rn = 1;
This is one of the most frequently used window-function patterns.
2. RANK()
Purpose: Assigns ranking numbers based on the specified ordering.
If two or more rows have the same value, they receive the same rank. The next rank contains a gap.
Example data:
| emp_name | salary |
|---|---|
| John | 7000 |
| Alice | 7000 |
| Bob | 6000 |
| David | 5000 |
| Emma | 5000 |
Query:
SELECT
emp_name,
salary,
RANK() OVER (
ORDER BY salary DESC
) AS salary_rank
FROM employees;
Result:
| emp_name | salary | salary_rank |
|---|---|---|
| John | 7000 | 1 |
| Alice | 7000 | 1 |
| Bob | 6000 | 3 |
| David | 5000 | 4 |
| Emma | 5000 | 4 |
Why does Bob get rank 3 instead of 2?
Because John and Alice both occupy rank 1.
The next available rank is 3.
Ranking sequence:
1, 1, 3, 4, 4
Real-world use cases:
- Employee salary rankings.
- Competition leaderboards.
- Product popularity rankings.
- Student examination rankings.
3. DENSE_RANK()
Purpose: Similar to RANK(), but it does not skip ranking numbers.
Query:
SELECT
emp_name,
salary,
DENSE_RANK() OVER (
ORDER BY salary DESC
) AS salary_rank
FROM employees;
Result:
| emp_name | salary | dense_rank |
|---|---|---|
| John | 7000 | 1 |
| Alice | 7000 | 1 |
| Bob | 6000 | 2 |
| David | 5000 | 3 |
| Emma | 5000 | 3 |
Compare the three ranking functions:
| Salary | ROW_NUMBER | RANK | DENSE_RANK |
|---|---|---|---|
| 7000 | 1 | 1 | 1 |
| 7000 | 2 | 1 | 1 |
| 6000 | 3 | 3 | 2 |
| 5000 | 4 | 4 | 3 |
| 5000 | 5 | 4 | 3 |
Important interview concept
Remember:
ROW_NUMBER()→ Always generates unique sequential numbers.RANK()→ Same rank for ties, skips subsequent numbers.DENSE_RANK()→ Same rank for ties, does not skip numbers.
Category 2: Distribution Functions
These functions help you understand the relative position of a row within a dataset.
4. PERCENT_RANK()
Purpose: Calculates the relative rank of a row as a percentage between 0 and 1.
Syntax:
PERCENT_RANK() OVER (
PARTITION BY column_name
ORDER BY column_name
)
Example:
SELECT
emp_name,
salary,
PERCENT_RANK() OVER (
ORDER BY salary
) AS percentage_rank
FROM employees;
For five rows with distinct salaries:
| Salary | RANK | PERCENT_RANK |
|---|---|---|
| 4000 | 1 | 0 |
| 5000 | 2 | 0.25 |
| 6000 | 3 | 0.50 |
| 7000 | 4 | 0.75 |
| 8000 | 5 | 1 |
How to interpret it:
0means the first ranked position.0.25means 25%.0.50means 50%.1means the highest ranked position.
Note that ties use the same rank, so percentages can also be repeated.
Real-world use cases:
- Finding an employee’s relative salary position.
- Analyzing student performance.
- Understanding customer spending distribution.
- Comparing product performance.
5. CUME_DIST()
Purpose: Calculates the cumulative distribution of a row.
It returns the proportion of rows whose ordering value is less than or equal to the current row’s value.
Example:
SELECT
emp_name,
salary,
CUME_DIST() OVER (
ORDER BY salary
) AS cumulative_distribution
FROM employees;
Assume these salaries:
| Employee | Salary |
|---|---|
| John | 4000 |
| Alice | 5000 |
| Bob | 5000 |
| David | 6000 |
| Emma | 8000 |
Result:
| Employee | Salary | CUME_DIST |
|---|---|---|
| John | 4000 | 0.20 |
| Alice | 5000 | 0.60 |
| Bob | 5000 | 0.60 |
| David | 6000 | 0.80 |
| Emma | 8000 | 1.00 |
Why does Alice get 0.60?
There are three rows with salary less than or equal to 5000.
Total rows = 5.
Therefore:
3/5 = 0.60
Difference between PERCENT_RANK() and CUME_DIST():
| PERCENT_RANK | CUME_DIST |
|---|---|
| Uses rank minus 1 | Uses cumulative row count |
| Formula: (rank - 1) / (N - 1) | Formula: cumulative count / N |
| First ranking position is 0 | First row can be greater than 0 |
| Maximum is 1 | Maximum is 1 |
6. NTILE(N)
Purpose: Divides rows into a specified number of approximately equal-sized groups (buckets).
Syntax:
NTILE(number_of_groups) OVER (
ORDER BY column_name
)
Example:
SELECT
emp_name,
salary,
NTILE(4) OVER (
ORDER BY salary DESC
) AS salary_group
FROM employees;
Suppose there are 12 employees.
The result divides them into four groups:
| Salary position | Bucket |
|---|---|
| Highest 3 employees | 1 |
| Next 3 employees | 2 |
| Next 3 employees | 3 |
| Lowest 3 employees | 4 |
Real-world use cases:
- Dividing customers into four spending groups.
- Segmenting employees by salary.
- Dividing students into performance groups.
- Creating quartiles or deciles for analytics.
For example, NTILE(10) divides records into ten groups, often used for decile analysis.
Important: When the number of rows cannot be divided equally, some buckets receive one extra row.
Category 3: Navigation Functions
Navigation functions allow you to access values from other rows without joining the same table to itself.
These are particularly useful for comparing current and previous records.
7. LAG()
Very important
Purpose: Retrieves a value from a previous row.
Syntax:
LAG(expression, offset, default_value)
OVER (
PARTITION BY column_name
ORDER BY column_name
)
Parameters:
expression: Column whose value you want.offset: How many rows backward to look.default_value: Value to return if the previous row does not exist.
Example: Consider a monthly sales table.
monthly_sales
| month | sales |
|---|---|
| January | 1000 |
| February | 1500 |
| March | 1200 |
| April | 2000 |
| May | 2500 |
Query:
SELECT
month,
sales,
LAG(sales) OVER (
ORDER BY month_id
) AS previous_month_sales
FROM monthly_sales;
Result:
| Month | Sales | Previous sales |
|---|---|---|
| January | 1000 | NULL |
| February | 1500 | 1000 |
| March | 1200 | 1500 |
| April | 2000 | 1200 |
| May | 2500 | 2000 |
Notice that LAG() retrieves the value from the previous row.
Example 2: Calculate month-over-month sales difference
SELECT
month,
sales,
LAG(sales) OVER (
ORDER BY month_id
) AS previous_sales,
sales - LAG(sales) OVER (
ORDER BY month_id
) AS sales_difference
FROM monthly_sales;
Result:
| Month | Sales | Previous sales | Difference |
|---|---|---|---|
| January | 1000 | NULL | NULL |
| February | 1500 | 1000 | 500 |
| March | 1200 | 1500 | -300 |
| April | 2000 | 1200 | 800 |
| May | 2500 | 2000 | 500 |
Example 3: Calculate percentage growth
SELECT
month,
sales,
LAG(sales) OVER (
ORDER BY month_id
) AS previous_sales,
ROUND(
(
sales - LAG(sales) OVER (
ORDER BY month_id
)
) * 100.0 /
NULLIF(
LAG(sales) OVER (
ORDER BY month_id
), 0
),
2
) AS growth_percentage
FROM monthly_sales;
This is useful for:
- Month-over-month comparisons.
- Year-over-year comparisons.
- Tracking changes in account balances.
- Detecting changes in customer activity.
- Comparing consecutive transactions.
8. LEAD()
Purpose: Retrieves a value from a future row.
It is the opposite of LAG().
Syntax:
LEAD(expression, offset, default_value)
OVER (
PARTITION BY column_name
ORDER BY column_name
)
Example:
SELECT
month,
sales,
LEAD(sales) OVER (
ORDER BY month_id
) AS next_month_sales
FROM monthly_sales;
Result:
| Month | Sales | Next month sales |
|---|---|---|
| January | 1000 | 1500 |
| February | 1500 | 1200 |
| March | 1200 | 2000 |
| April | 2000 | 2500 |
| May | 2500 | NULL |
Difference between LAG and LEAD:
| LAG() | LEAD() |
|---|---|
| Looks backward | Looks forward |
| Gets previous row’s value | Gets next row’s value |
| Useful for historical comparisons | Useful for future comparisons |
Example: Compare the current transaction with the next transaction.
SELECT
transaction_id,
transaction_date,
amount,
LEAD(amount, 1, 0) OVER (
ORDER BY transaction_date, transaction_id
) AS next_transaction_amount
FROM transactions;
Category 4: Value Functions
These functions retrieve specific values from a window frame.
9. FIRST_VALUE()
Purpose: Returns the value from the first row of the current window frame.
Syntax:
FIRST_VALUE(expression) OVER (
PARTITION BY column_name
ORDER BY column_name
)
Example: Find the highest salary in each department.
SELECT
emp_name,
department,
salary,
FIRST_VALUE(emp_name) OVER (
PARTITION BY department
ORDER BY salary DESC, emp_id
) AS highest_paid_employee
FROM employees;
Result:
| Employee | Department | Salary | Highest paid employee |
|---|---|---|---|
| Alice | IT | 7000 | Alice |
| Bob | IT | 6000 | Alice |
| John | IT | 5000 | Alice |
| Mike | HR | 6000 | Mike |
| Emma | HR | 5000 | Mike |
| David | HR | 4000 | Mike |
How does it work?
- Divides employees by department.
- Sorts salaries in descending order.
- Retrieves the first employee name in each department’s window frame.
Use cases:
- Finding the highest-paid employee.
- Finding the earliest transaction.
- Identifying the first event in a customer journey.
- Finding the first recorded status.
10. LAST_VALUE()
Purpose: Returns the value from the last row of the current window frame.
Example:
SELECT
emp_name,
department,
salary,
LAST_VALUE(emp_name) OVER (
PARTITION BY department
ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING
) AS highest_salary_employee
FROM employees;
Here we explicitly define the frame to include the entire department.
Why is the frame important?
A common mistake is writing:
LAST_VALUE(emp_name) OVER (
PARTITION BY department
ORDER BY salary
)
This does not necessarily return the last employee in the entire department. With an ordered window, MySQL’s default frame ends at the current row’s peer group.
To get the last value from the entire partition, use:
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING
Common use cases:
- Finding the latest transaction.
- Getting the final status of an order.
- Retrieving the last event in a process.
- Identifying the highest or lowest ordered value.
11. NTH_VALUE()
Purpose: Retrieves the value from the Nth row of the current window frame.
Syntax:
NTH_VALUE(expression, N) OVER (
PARTITION BY column_name
ORDER BY column_name
ROWS BETWEEN ...
)
Example: Find the second-highest salary in each department.
SELECT
emp_name,
department,
salary,
NTH_VALUE(emp_name, 2) OVER (
PARTITION BY department
ORDER BY salary DESC, emp_id
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING
) AS second_highest_employee
FROM employees;
The result identifies the employee in the second position in each department.
Important: NTH_VALUE() retrieves the Nth row, not the Nth distinct salary.
If two employees share the highest salary, the second row might still have that same salary.
Use cases:
- Finding the second transaction.
- Finding the third-highest record.
- Retrieving the Nth event.
- Analyzing ordered business events.
4. Aggregate Functions Used as Window Functions
One important thing to understand is that MySQL also allows normal aggregate functions to work as window functions by adding OVER().
These are not additional dedicated window functions, but they are extremely important.
The main aggregate functions are:
| Function | Purpose |
|---|---|
SUM() |
Calculates the sum |
AVG() |
Calculates the average |
COUNT() |
Counts rows |
MIN() |
Finds the minimum value |
MAX() |
Finds the maximum value |
BIT_AND() |
Bitwise AND aggregation |
BIT_OR() |
Bitwise OR aggregation |
BIT_XOR() |
Bitwise XOR aggregation |
STDDEV_POP() |
Population standard deviation |
STDDEV_SAMP() |
Sample standard deviation |
VAR_POP() |
Population variance |
VAR_SAMP() |
Sample variance |
JSON_ARRAYAGG() |
Aggregates values into a JSON array |
JSON_OBJECTAGG() |
Aggregates key-value pairs into a JSON object |
Let’s understand the most commonly used ones.
12. SUM() OVER()
Purpose: Calculates the sum across a window without removing individual rows.
Example: Calculate the total salary of each department.
SELECT
emp_name,
department,
salary,
SUM(salary) OVER (
PARTITION BY department
) AS department_total_salary
FROM employees;
Example: Calculate running total salary.
SELECT
emp_name,
salary,
SUM(salary) OVER (
ORDER BY emp_id
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
) AS running_total
FROM employees;
If the salaries are 5000, 7000, 6000, the running totals will be:
| Employee | Salary | Running total |
|---|---|---|
| John | 5000 | 5000 |
| Alice | 7000 | 12000 |
| Bob | 6000 | 18000 |
13. AVG() OVER()
Purpose: Calculates the average across a window.
Example: Calculate department-wise average salary.
SELECT
emp_name,
department,
salary,
AVG(salary) OVER (
PARTITION BY department
) AS department_avg_salary
FROM employees;
Example: Calculate a moving average of the last three records.
SELECT
month_id,
month,
sales,
AVG(sales) OVER (
ORDER BY month_id
ROWS BETWEEN 2 PRECEDING
AND CURRENT ROW
) AS moving_average
FROM monthly_sales;
This calculates the average of:
- Current month.
- Previous month.
- Two months before the current month.
This is particularly useful for financial reporting and sales trend analysis.
14. COUNT() OVER()
Purpose: Counts records in a window.
Example: Count employees in each department.
SELECT
emp_name,
department,
COUNT(*) OVER (
PARTITION BY department
) AS department_employee_count
FROM employees;
Example: Calculate cumulative employee count.
SELECT
emp_id,
emp_name,
COUNT(*) OVER (
ORDER BY emp_id
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
) AS cumulative_count
FROM employees;
15. MIN() OVER()
Purpose: Finds the minimum value within a window.
SELECT
emp_name,
department,
salary,
MIN(salary) OVER (
PARTITION BY department
) AS minimum_department_salary
FROM employees;
Every employee receives the minimum salary of their department.
16. MAX() OVER()
Purpose: Finds the maximum value within a window.
SELECT
emp_name,
department,
salary,
MAX(salary) OVER (
PARTITION BY department
) AS maximum_department_salary
FROM employees;
Every employee receives the maximum salary of their department.
5. Window Frame Specification – ROWS and RANGE
This is one of the most important advanced concepts in MySQL window functions.
A window frame determines which rows participate in a calculation for the current row.
Consider:
SUM(salary) OVER (
ORDER BY emp_id
ROWS BETWEEN 2 PRECEDING
AND CURRENT ROW
)
This means:
- Include two preceding rows.
- Include the current row.
- Calculate the sum.
There are several frame boundaries.
| Frame boundary | Meaning |
|---|---|
UNBOUNDED PRECEDING |
Start from the first row |
N PRECEDING |
N rows before current row |
CURRENT ROW |
Current row |
N FOLLOWING |
N rows after current row |
UNBOUNDED FOLLOWING |
Last row |
Common frame examples
Example 1: Running total
SUM(salary) OVER (
ORDER BY emp_id
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
)
Calculates the sum from the first row to the current row.
Example 2: Moving average of three rows
AVG(salary) OVER (
ORDER BY emp_id
ROWS BETWEEN 2 PRECEDING
AND CURRENT ROW
)
Calculates the average of the current row and two preceding rows.
Example 3: Next two rows
SUM(salary) OVER (
ORDER BY emp_id
ROWS BETWEEN CURRENT ROW
AND 2 FOLLOWING
)
Calculates the sum of the current row and the next two rows.
Example 4: Entire dataset
SUM(salary) OVER (
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING
)
Calculates the total salary across all rows.
Difference between ROWS and RANGE
This is a common interview question.
ROWSworks with physical row positions.RANGEworks with ordering values and peer groups.
Suppose the data is:
| Employee | Salary |
|---|---|
| John | 5000 |
| Alice | 5000 |
| Bob | 6000 |
| David | 7000 |
Using ROWS:
SUM(salary) OVER (
ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
)
The calculation advances one physical row at a time.
Using RANGE:
SUM(salary) OVER (
ORDER BY salary
RANGE BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
)
Rows with the same ordering value are peers. Therefore, both employees earning 5000 receive the same cumulative total.
| Employee | Salary | ROWS total | RANGE total |
|---|---|---|---|
| John | 5000 | 5000* | 10000 |
| Alice | 5000 | 10000* | 10000 |
| Bob | 6000 | 16000 | 16000 |
| David | 7000 | 23000 | 23000 |
*The order between John and Alice is not guaranteed unless you add a tie-breaking column to ORDER BY. The illustrated ROWS totals assume John comes first.
When should you use each?
| ROWS | RANGE |
|---|---|
| Physical row-based calculations | Value/peer-based calculations |
| Running totals by record | Cumulative calculations including ties |
| Moving averages over the last N records | Calculations based on ordering values |
6. Named Windows – WINDOW Clause
When you need to use the same window definition multiple times, you can create a named window.
Without a named window:
SELECT
emp_name,
department,
salary,
RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS salary_rank,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS dense_salary_rank,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS row_num
FROM employees;
With a named window:
SELECT
emp_name,
department,
salary,
RANK() OVER w AS salary_rank,
DENSE_RANK() OVER w AS dense_salary_rank,
ROW_NUMBER() OVER w AS row_num
FROM employees
WINDOW w AS (
PARTITION BY department
ORDER BY salary DESC
);
Both queries produce equivalent results.
Advantages:
- Avoids repeating the same window definition.
- Makes queries easier to read.
- Easier to maintain.
- Useful when several window functions use the same partition and ordering.
7. Real-World SQL Problems Using Window Functions
Now let’s look at some practical problems that frequently appear in SQL interviews and real projects.
Problem 1: Find the second-highest salary in each department
SELECT *
FROM (
SELECT
emp_name,
department,
salary,
DENSE_RANK() OVER (
PARTITION BY department
ORDER BY salary DESC
) AS salary_rank
FROM employees
) AS ranked
WHERE salary_rank = 2;
Why DENSE_RANK()?
Because we want the second-highest distinct salary, even when multiple employees have the same salary.
Problem 2: Find the top three employees in each department
SELECT *
FROM (
SELECT
emp_name,
department,
salary,
ROW_NUMBER() OVER (
PARTITION BY department
ORDER BY salary DESC, emp_id
) AS rn
FROM employees
) AS ranked
WHERE rn <= 3;
This returns a maximum of three employees per department.
If you want to include everyone tied at the third rank, use RANK() instead.
Problem 3: Find duplicate records
Suppose you have a customer table with duplicate email addresses.
SELECT *
FROM (
SELECT
customer_id,
customer_name,
email,
ROW_NUMBER() OVER (
PARTITION BY email
ORDER BY customer_id
) AS rn
FROM customers
) AS duplicates
WHERE rn > 1;
Explanation:
PARTITION BY emailgroups customers with the same email.ROW_NUMBER()assigns numbers within each group.rn = 1identifies the first record.rn > 1identifies the additional records.
You can use this result to identify duplicates before deciding which records to retain.
Problem 4: Find the latest transaction for every customer
SELECT *
FROM (
SELECT
transaction_id,
customer_id,
transaction_date,
amount,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY transaction_date DESC,
transaction_id DESC
) AS rn
FROM transactions
) AS latest_transactions
WHERE rn = 1;
This is a very common business requirement.
Problem 5: Calculate cumulative sales for each customer
SELECT
customer_id,
transaction_date,
amount,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY transaction_date, transaction_id
ROWS BETWEEN UNBOUNDED PRECEDING
AND CURRENT ROW
) AS cumulative_sales
FROM transactions;
This calculates the running sales total separately for every customer.
Problem 6: Compare current salary with department average
SELECT
emp_name,
department,
salary,
AVG(salary) OVER (
PARTITION BY department
) AS avg_salary,
salary - AVG(salary) OVER (
PARTITION BY department
) AS salary_difference
FROM employees;
This helps identify employees earning above or below their department average.
Problem 7: Find employees earning more than their department average
SELECT *
FROM (
SELECT
emp_name,
department,
salary,
AVG(salary) OVER (
PARTITION BY department
) AS avg_salary
FROM employees
) AS employee_salary
WHERE salary > avg_salary;
Problem 8: Calculate the difference between consecutive transactions
SELECT
customer_id,
transaction_date,
amount,
amount - LAG(amount) OVER (
PARTITION BY customer_id
ORDER BY transaction_date, transaction_id
) AS amount_difference
FROM transactions;
This compares each customer’s transaction amount with their previous transaction.
8. Important Differences and Interview Questions
Here are the concepts you should remember.
| Question | Answer |
|---|---|
| Does a window function eliminate rows? | No |
| Does GROUP BY eliminate individual rows? | Yes, it groups rows |
| What is the purpose of OVER()? | Defines the window |
| What is PARTITION BY? | Divides rows into groups |
| What is ORDER BY inside OVER()? | Defines the processing order |
| What does ROW_NUMBER() do? | Assigns unique sequential numbers |
| What does RANK() do? | Assigns ranks with gaps |
| What does DENSE_RANK() do? | Assigns ranks without gaps |
| What does LAG() do? | Retrieves a previous row’s value |
| What does LEAD() do? | Retrieves a following row’s value |
| What does NTILE(4) do? | Divides rows into four buckets |
| What does FIRST_VALUE() do? | Retrieves the first value in the frame |
| What does LAST_VALUE() do? | Retrieves the last value in the frame |
| What does NTH_VALUE() do? | Retrieves the Nth value in the frame |
9. Important MySQL Window Function Rules
These are important when writing actual SQL queries.
1. Window functions require MySQL 8.0 or later.
2. Window functions can be used in the SELECT list and ORDER BY clause.
You cannot directly use a window function in the WHERE clause.
Incorrect:
SELECT *
FROM employees
WHERE ROW_NUMBER() OVER (
ORDER BY salary DESC
) = 1;
Correct:
SELECT *
FROM (
SELECT
*,
ROW_NUMBER() OVER (
ORDER BY salary DESC
) AS rn
FROM employees
) AS result
WHERE rn = 1;
3. Use a deterministic ORDER BY when necessary.
If multiple rows have the same value, add a unique column as a tie-breaker when you need consistent results.
4. Be careful with LAST_VALUE().
Use an explicit frame when you need the last value from the entire partition:
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING
5. LAG() and LEAD() have useful defaults.
LAG(salary, 1, 0)
This returns 0 when there is no preceding row.
6. Window functions are evaluated after WHERE, GROUP BY and HAVING, but before the final ORDER BY and LIMIT.
This explains why you generally need a subquery or CTE to filter their results.
Sources: Official MySQL 8.4 documentation:
10. Recommended Learning Order
If you are learning MySQL window functions for interviews or practical development, I recommend this order:
MySQL Window Functions Learning Roadmap
-
Fundamentals
OVER(),PARTITION BY,ORDER BYStart here
-
Ranking functions
ROW_NUMBER(),RANK(),DENSE_RANK() -
Navigation functions
LAG(),LEAD() -
Aggregate window functions
SUM(),AVG(),COUNT(),MIN(),MAX() -
Window frames
ROWS,RANGE,PRECEDING,FOLLOWING -
Advanced functions
NTILE(),PERCENT_RANK(),CUME_DIST(),FIRST_VALUE(),LAST_VALUE(),NTH_VALUE() -
Real-world SQL problems
Top N per group, duplicates, running totals, latest records and salary comparisons.