Skip to content

Introduction to SQL Window Functions: OVER()

Learn how SQL window functions with OVER() enable ranking, running totals, and group comparisons while preserving all rows, unlike traditional aggregate functions.

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

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:

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

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:

  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.

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.

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:

  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.

Navigation