Skip to main content

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
tip

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 consider
  • PARTITION BY (optional): Divides rows into groups (partitions) for the function to operate on independently
  • ORDER 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:

idnamedepartmentsalary
1AliceEngineering90000
2BobEngineering85000
3CharlieSales60000
4DianaSales65000
5EveMarketing70000
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:

namedepartmentsalarytotal_salary
AliceEngineering90000370000
BobEngineering85000370000
CharlieSales60000370000
DianaSales65000370000
EveMarketing70000370000

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:

departmentdept_salary
Marketing70000
Engineering175000
Sales125000

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:

namedepartmentsalarydept_salary
AliceEngineering90000175000
BobEngineering85000175000
DianaSales65000125000
CharlieSales60000125000
EveMarketing7000070000

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

FeatureAggregate FunctionWindow Function
Reduces row count?Yes (via GROUP BY)No
Requires GROUP BY?Yes (for grouped results)No
Uses OVER() clause?NoYes
Access to individual rows?NoYes
Can coexist with detail columns?Only if groupedAlways
Common Mistake

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;
namedepartmenttotal_salary
AliceEngineering370000.00
BobEngineering370000.00
CharlieSales370000.00
DianaSales370000.00
EveMarketing370000.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:

namedepartmentsalaryavg_dept_salary
AliceEngineering9000087500.00
BobEngineering8500087500.00
EveMarketing7000070000.00
CharlieSales6000062500.00
DianaSales6500062500.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:

namedepartmentsalarydept_headcount
AliceEngineering900002
BobEngineering850002
EveMarketing700001
CharlieSales600002
DianaSales650002

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:

idsalespersonregionsale_dateamount
1AliceNorth2024-01-10200
2AliceNorth2024-01-15350
3AliceNorth2024-01-22150
4BobSouth2024-01-08400
5BobSouth2024-01-18250
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:

salespersonregionsale_dateamountrunning_total
AliceNorth2024-01-10200.00200.00
AliceNorth2024-01-15350.00550.00
AliceNorth2024-01-22150.00700.00
BobSouth2024-01-08400.00400.00
BobSouth2024-01-18250.00650.00

Here is what happened step by step:

  1. PARTITION BY salesperson created two partitions: one for Alice, one for Bob.
  2. ORDER BY sale_date sorted rows within each partition by date.
  3. SUM(amount) computed a running total within each partition, adding the current row's amount to all previous rows' amounts.
Why does ORDER BY change the behavior?

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;
salespersonsale_yearsale_dateamountyearly_running_total
Alice20242024-01-10200.00200.00
Alice20242024-01-15350.00550.00
Alice20242024-01-22150.00700.00
Bob20242024-01-08400.00400.00
Bob20242024-01-18250.00650.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:

namedepartmentsalarydept_totalpct_of_dept
AliceEngineering90000.00175000.0051.43
BobEngineering85000.00175000.0048.57
EveMarketing70000.0070000.00100.00
CharlieSales60000.00125000.0048.00
DianaSales65000.00125000.0052.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:

namedepartmentsalarydept_avgdiff_from_avg
AliceEngineering90000.0087500.002500.00
BobEngineering85000.0087500.00-2500.00
EveMarketing70000.0070000.000.00
CharlieSales60000.0062500.00-2500.00
DianaSales65000.0062500.002500.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:

namedepartmentsalaryrank_in_dept
AliceEngineering900001
BobEngineering850002
EveMarketing700001
DianaSales650001
CharlieSales600002

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.

note

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;
namesalarytotal
Alice90000.00370000.00
Bob85000.00370000.00
Charlie60000.00370000.00
Diana65000.00370000.00
Eve70000.00370000.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:

departmentdept_salarycompany_total
Marketing70000370000
Engineering175000370000
Sales125000370000

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:

  1. FROM and JOIN
  2. WHERE
  3. GROUP BY
  4. HAVING
  5. Window Functions (evaluated here)
  6. SELECT
  7. DISTINCT
  8. ORDER BY
  9. LIMIT

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 empty OVER() means the entire result set is the window.
  • Aggregate functions with GROUP BY reduce rows; window functions with OVER() preserve every row.
  • PARTITION BY divides your data into independent groups for the window function to compute over, similar to GROUP BY but without collapsing.
  • Adding ORDER BY inside OVER() 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.

Next Steps

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.