Introduction to SQL Window Functions: OVER()
If you have ever needed to rank rows, calculate running totals, or compare a value against an aggregate without collapsing your result set, then SQL window functions are exactly what you need. They are one of the most powerful yet often misunderstood features in SQL, and mastering them will dramatically level up your querying skills.
This guide will walk you through what SQL window functions are, how the OVER() clause works, how window functions differ from regular aggregate functions, and how PARTITION BY lets you create logical groups within your data. Every concept is paired with practical examples and their outputs so you can follow along immediately.
What Are SQL Window Functions and Why They Matter
SQL window functions perform calculations across a set of rows that are somehow related to the current row. This set of rows is called a window (or window frame). Unlike regular aggregate functions like SUM() or COUNT(), window functions do not collapse rows into a single output row. Instead, each row in your result set retains its own identity while still having access to aggregated or computed information from other rows.
Think of it this way: a window function lets you "look through a window" at a group of related rows, perform a calculation, and then attach the result back to each individual row.
Why Do They Matter?
Before window functions existed in SQL, achieving the same results required complicated self-joins, correlated subqueries, or temporary tables. Window functions solve these problems elegantly and with significantly better performance.
Here are some common real-world use cases for SQL window functions:
- Ranking employees by salary within each department
- Computing a running total of sales over time
- Finding the difference between a current row's value and the previous one
- Calculating a moving average over the last N rows
- Identifying top N records per group
SQL window functions are supported by all major relational databases, including PostgreSQL, MySQL 8+, SQL Server, Oracle, and SQLite 3.25+. If you are using an older version of MySQL (before 8.0), window functions are not available.
The OVER() Clause
The OVER() clause is what transforms a regular aggregate function into a window function. It defines the "window" of rows over which the function operates. Without OVER(), there are no window functions.
Basic Syntax
function_name(expression) OVER (
[PARTITION BY column1, column2, ...]
[ORDER BY column3, column4, ...]
)
function_name: Any window function or aggregate function (e.g.,SUM,COUNT,ROW_NUMBER,RANK, etc.)OVER(): Defines the window of rows the function should considerPARTITION BY(optional): Divides rows into groups (partitions) for the function to operate on independentlyORDER BY(optional): Determines the order of rows within each partition
OVER() with an Empty Parenthesis
When you use OVER() with nothing inside the parentheses, the window spans the entire result set. Every row sees the same aggregated value.
Let's work with a sample table called employees:
| id | name | department | salary |
|---|---|---|---|
| 1 | Alice | Engineering | 90000 |
| 2 | Bob | Engineering | 85000 |
| 3 | Charlie | Sales | 60000 |
| 4 | Diana | Sales | 65000 |
| 5 | Eve | Marketing | 70000 |
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(50),
department VARCHAR(50),
salary DECIMAL(10,2)
);
INSERT INTO employees (id, name, department, salary) VALUES
(1, 'Alice', 'Engineering', 90000),
(2, 'Bob', 'Engineering', 85000),
(3, 'Charlie', 'Sales', 60000),
(4, 'Diana', 'Sales', 65000),
(5, 'Eve', 'Marketing', 70000);
And let's do the query with OVER()
SELECT
name,
department,
salary,
SUM(salary) OVER() AS total_salary
FROM employees;
Output:
| name | department | salary | total_salary |
|---|---|---|---|
| Alice | Engineering | 90000 | 370000 |
| Bob | Engineering | 85000 | 370000 |
| Charlie | Sales | 60000 | 370000 |
| Diana | Sales | 65000 | 370000 |
| Eve | Marketing | 70000 | 370000 |
Notice how every row still appears individually, but each one carries the total salary across all employees (370000). This is the fundamental behavior of SQL window functions: compute across rows without reducing the number of rows.
Difference Between Aggregate and Window Functions
This is one of the most critical concepts to understand. At first glance, aggregate functions and window functions look similar because they both perform calculations like SUM(), AVG(), or COUNT(). However, they behave very differently.
Aggregate Functions Collapse Rows
When you use a regular aggregate function with GROUP BY, the result set is collapsed. Multiple rows become one.
SELECT
department,
SUM(salary) AS dept_salary
FROM employees
GROUP BY department;
Output:
| department | dept_salary |
|---|---|
| Marketing | 70000 |
| Engineering | 175000 |
| Sales | 125000 |
You started with 5 rows and ended up with 3 rows. The individual employee information is gone.
Window Functions Preserve Rows
The same calculation using a window function retains every single row:
SELECT
name,
department,
salary,
SUM(salary) OVER(PARTITION BY department) AS dept_salary
FROM employees;
Output:
| name | department | salary | dept_salary |
|---|---|---|---|
| Alice | Engineering | 90000 | 175000 |
| Bob | Engineering | 85000 | 175000 |
| Diana | Sales | 65000 | 125000 |
| Charlie | Sales | 60000 | 125000 |
| Eve | Marketing | 70000 | 70000 |
All 5 rows are still there. Each row now shows both its individual salary and the total salary for its department. This is exactly why SQL window functions are so valuable: you get detail and summary in the same query.
Side-by-Side Comparison
| Feature | Aggregate Function | Window Function |
|---|---|---|
| Reduces row count? | Yes (via GROUP BY) | No |
Requires GROUP BY? | Yes (for grouped results) | No |
Uses OVER() clause? | No | Yes |
| Access to individual rows? | No | Yes |
| Can coexist with detail columns? | Only if grouped | Always |
A frequent error for beginners is trying to select non-aggregated columns alongside an aggregate function without GROUP BY. This will throw an error in most databases.
-- This will FAIL
SELECT
name,
department,
SUM(salary)
FROM employees;
Error: column "name" must appear in the GROUP BY clause or be used in an aggregate function
The window function version solves this naturally:
-- This works perfectly
SELECT
name,
department,
SUM(salary) OVER() AS total_salary
FROM employees;
| name | department | total_salary |
|---|---|---|
| Alice | Engineering | 370000.00 |
| Bob | Engineering | 370000.00 |
| Charlie | Sales | 370000.00 |
| Diana | Sales | 370000.00 |
| Eve | Marketing | 370000.00 |
PARTITION BY Explained
PARTITION BY is the component inside the OVER() clause that divides your data into logical groups (called partitions). The window function then performs its calculation independently within each partition, as if each group were its own mini table.
If you are familiar with GROUP BY, think of PARTITION BY as its window function equivalent, but with the crucial difference that rows are not collapsed.
Syntax
function_name(expression) OVER (
PARTITION BY column1, column2
)
Basic Example
Let's calculate the average salary per department while keeping every row visible:
SELECT
name,
department,
salary,
ROUND(AVG(salary) OVER(PARTITION BY department), 2) AS avg_dept_salary
FROM employees;
Output:
| name | department | salary | avg_dept_salary |
|---|---|---|---|
| Alice | Engineering | 90000 | 87500.00 |
| Bob | Engineering | 85000 | 87500.00 |
| Eve | Marketing | 70000 | 70000.00 |
| Charlie | Sales | 60000 | 62500.00 |
| Diana | Sales | 65000 | 62500.00 |
The AVG() function was calculated separately for each department:
- Engineering: (90000 + 85000) / 2 = 87500
- Sales: (60000 + 65000) / 2 = 62500
- Marketing: 70000 / 1 = 70000
Counting Rows per Partition
You can use COUNT() with PARTITION BY to see how many employees are in each department:
SELECT
name,
department,
salary,
COUNT(*) OVER(PARTITION BY department) AS dept_headcount
FROM employees;
Output:
| name | department | salary | dept_headcount |
|---|---|---|---|
| Alice | Engineering | 90000 | 2 |
| Bob | Engineering | 85000 | 2 |
| Eve | Marketing | 70000 | 1 |
| Charlie | Sales | 60000 | 2 |
| Diana | Sales | 65000 | 2 |
Combining PARTITION BY with ORDER BY
When you add ORDER BY inside the OVER() clause alongside PARTITION BY, the function becomes order-aware. For aggregate functions like SUM(), this turns them into running (cumulative) calculations.
Let's use an expanded dataset to illustrate this better. Consider a sales table:
| id | salesperson | region | sale_date | amount |
|---|---|---|---|---|
| 1 | Alice | North | 2024-01-10 | 200 |
| 2 | Alice | North | 2024-01-15 | 350 |
| 3 | Alice | North | 2024-01-22 | 150 |
| 4 | Bob | South | 2024-01-08 | 400 |
| 5 | Bob | South | 2024-01-18 | 250 |
CREATE TABLE sales (
id INT PRIMARY KEY,
salesperson VARCHAR(50),
region VARCHAR(50),
sale_date DATE,
amount DECIMAL(10,2)
);
INSERT INTO sales (id, salesperson, region, sale_date, amount) VALUES
(1, 'Alice', 'North', '2024-01-10', 200),
(2, 'Alice', 'North', '2024-01-15', 350),
(3, 'Alice', 'North', '2024-01-22', 150),
(4, 'Bob', 'South', '2024-01-08', 400),
(5, 'Bob', 'South', '2024-01-18', 250);
SELECT
salesperson,
region,
sale_date,
amount,
SUM(amount) OVER(PARTITION BY salesperson ORDER BY sale_date) AS running_total
FROM sales;
Output:
| salesperson | region | sale_date | amount | running_total |
|---|---|---|---|---|
| Alice | North | 2024-01-10 | 200.00 | 200.00 |
| Alice | North | 2024-01-15 | 350.00 | 550.00 |
| Alice | North | 2024-01-22 | 150.00 | 700.00 |
| Bob | South | 2024-01-08 | 400.00 | 400.00 |
| Bob | South | 2024-01-18 | 250.00 | 650.00 |
Here is what happened step by step:
PARTITION BY salespersoncreated two partitions: one for Alice, one for Bob.ORDER BY sale_datesorted rows within each partition by date.SUM(amount)computed a running total within each partition, adding the current row's amount to all previous rows' amounts.
Without ORDER BY, a SUM() OVER(PARTITION BY ...) gives you the total for the entire partition on every row. With ORDER BY, the default window frame changes to ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which means the sum accumulates row by row. This is why the result becomes a running total.
Partitioning by Multiple Columns
You can partition by more than one column. For instance, if your sales table had data across multiple years and you wanted a running total per salesperson per year:
SELECT
salesperson,
EXTRACT(YEAR FROM sale_date) AS sale_year,
sale_date,
amount,
SUM(amount) OVER(
PARTITION BY salesperson, EXTRACT(YEAR FROM sale_date)
ORDER BY sale_date
) AS yearly_running_total
FROM sales;
| salesperson | sale_year | sale_date | amount | yearly_running_total |
|---|---|---|---|---|
| Alice | 2024 | 2024-01-10 | 200.00 | 200.00 |
| Alice | 2024 | 2024-01-15 | 350.00 | 550.00 |
| Alice | 2024 | 2024-01-22 | 150.00 | 700.00 |
| Bob | 2024 | 2024-01-08 | 400.00 | 400.00 |
| Bob | 2024 | 2024-01-18 | 250.00 | 650.00 |
Each unique combination of salesperson and year becomes its own partition, and the running total resets for each one.
Practical Examples: Putting It All Together
Let's go through a few more practical scenarios that demonstrate the power of SQL window functions using our original employees table.
Example 1: Each Employee's Salary as a Percentage of Department Total
SELECT
name,
department,
salary,
SUM(salary) OVER(PARTITION BY department) AS dept_total,
ROUND(100.0 * salary / SUM(salary) OVER(PARTITION BY department), 2) AS pct_of_dept
FROM employees;
Output:
| name | department | salary | dept_total | pct_of_dept |
|---|---|---|---|---|
| Alice | Engineering | 90000.00 | 175000.00 | 51.43 |
| Bob | Engineering | 85000.00 | 175000.00 | 48.57 |
| Eve | Marketing | 70000.00 | 70000.00 | 100.00 |
| Charlie | Sales | 60000.00 | 125000.00 | 48.00 |
| Diana | Sales | 65000.00 | 125000.00 | 52.00 |
This query would be extremely cumbersome without window functions, likely requiring a subquery or a join back to an aggregated version of the table.
Example 2: Difference Between Each Salary and the Department Average
SELECT
name,
department,
salary,
ROUND(AVG(salary) OVER(PARTITION BY department), 2) AS dept_avg,
salary - ROUND(AVG(salary) OVER(PARTITION BY department), 2) AS diff_from_avg
FROM employees;
Output:
| name | department | salary | dept_avg | diff_from_avg |
|---|---|---|---|---|
| Alice | Engineering | 90000.00 | 87500.00 | 2500.00 |
| Bob | Engineering | 85000.00 | 87500.00 | -2500.00 |
| Eve | Marketing | 70000.00 | 70000.00 | 0.00 |
| Charlie | Sales | 60000.00 | 62500.00 | -2500.00 |
| Diana | Sales | 65000.00 | 62500.00 | 2500.00 |
Example 3: Using ROW_NUMBER() for Ranking
ROW_NUMBER() is a dedicated window function (it only works with OVER()). It assigns a unique sequential number to each row within a partition:
SELECT
name,
department,
salary,
ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS rank_in_dept
FROM employees;
Output:
| name | department | salary | rank_in_dept |
|---|---|---|---|
| Alice | Engineering | 90000 | 1 |
| Bob | Engineering | 85000 | 2 |
| Eve | Marketing | 70000 | 1 |
| Diana | Sales | 65000 | 1 |
| Charlie | Sales | 60000 | 2 |
This is invaluable when you need to find, say, the highest-paid employee in each department, by wrapping this in a subquery and filtering where rank_in_dept = 1.
ROW_NUMBER(), RANK(), DENSE_RANK(), NTILE(), LAG(), LEAD(), FIRST_VALUE(), and LAST_VALUE() are all dedicated window functions. They can only be used with the OVER() clause and cannot work as standalone aggregate functions. These will be covered in depth in future guides.
Common Mistakes to Avoid
Mistake 1: Using a Window Function in WHERE
Window functions cannot be used in the WHERE clause. They are evaluated after WHERE has already filtered the rows.
-- This will FAIL
SELECT name, salary, SUM(salary) OVER() AS total
FROM employees
WHERE SUM(salary) OVER() > 100000;
Error: window functions are not allowed in WHERE
Correct approach: Wrap the query in a subquery or CTE:
-- Use a subquery
SELECT * FROM (
SELECT name, salary, SUM(salary) OVER() AS total
FROM employees
) sub
WHERE total > 100000;
| name | salary | total |
|---|---|---|
| Alice | 90000.00 | 370000.00 |
| Bob | 85000.00 | 370000.00 |
| Charlie | 60000.00 | 370000.00 |
| Diana | 65000.00 | 370000.00 |
| Eve | 70000.00 | 370000.00 |
Mistake 2: Confusing PARTITION BY with GROUP BY
Remember: GROUP BY collapses rows, PARTITION BY does not. If your query has a GROUP BY and a window function, the window function operates on the already-grouped result set, not on the original rows.
-- This groups first, then applies the window function on the grouped result
SELECT
department,
SUM(salary) AS dept_salary,
SUM(SUM(salary)) OVER() AS company_total
FROM employees
GROUP BY department;
Output:
| department | dept_salary | company_total |
|---|---|---|
| Marketing | 70000 | 370000 |
| Engineering | 175000 | 370000 |
| Sales | 125000 | 370000 |
Notice the nested SUM(SUM(salary)). The inner SUM is the aggregate for the GROUP BY, and the outer SUM ... OVER() is the window function operating on those aggregated results.
Mistake 3: Forgetting That ORDER BY Changes Window Frame Behavior
As shown earlier, adding ORDER BY inside OVER() changes how aggregate functions compute their results. Without it, you get the full partition total. With it, you get a cumulative value.
-- Full department total on every row
SELECT name, department, salary,
SUM(salary) OVER(PARTITION BY department) AS dept_total
FROM employees;
-- Running total within the department (different result!)
SELECT name, department, salary,
SUM(salary) OVER(PARTITION BY department ORDER BY salary) AS running_total
FROM employees;
Be intentional about whether you include ORDER BY inside OVER().
SQL Order of Execution and Window Functions
Understanding when window functions are evaluated in the SQL execution pipeline helps avoid confusion:
FROMandJOINWHEREGROUP BYHAVING- Window Functions (evaluated here)
SELECTDISTINCTORDER BYLIMIT
This is why you cannot use window functions in WHERE or HAVING, but you can use them in SELECT and ORDER BY.
Summary
SQL window functions are an essential tool for any developer working with relational databases. Here is a quick recap of what was covered:
- Window functions perform calculations across a set of related rows without collapsing the result set.
- The
OVER()clause defines the window. An emptyOVER()means the entire result set is the window. - Aggregate functions with
GROUP BYreduce rows; window functions withOVER()preserve every row. PARTITION BYdivides your data into independent groups for the window function to compute over, similar toGROUP BYbut without collapsing.- Adding
ORDER BYinsideOVER()makes aggregate window functions compute running (cumulative) values.
With these fundamentals, you are now ready to explore more advanced window functions like RANK(), LAG(), LEAD(), and custom window frames, which build directly on everything you have learned here.
Practice these queries on your own database. Start by adding SUM(...) OVER() to an existing query and observe how the result differs from a regular GROUP BY. Once that clicks, experiment with PARTITION BY and ORDER BY inside OVER(). Muscle memory with window functions comes from hands-on repetition.