Window Functions
What Are Window Functions?
Window functions are one of the most powerful features in SQL, and one of the most misunderstood. Let's start with the core concept.
When you use GROUP BY with aggregate functions, the rows get collapsed β you lose the individual row details:
-- GROUP BY: gives you one row per department
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id;
-- Result:
-- DEPARTMENT_ID | AVG_SALARY
-- --------------|----------
-- 10 | 4400
-- 20 | 9500
-- 60 | 5760
-- (individual employees are gone)
Window functions compute over a set of rows without collapsing them. Each row keeps its identity, but gains access to calculations about its neighbors:
-- Window function: each employee row + their department average
SELECT employee_id, first_name, salary, department_id,
AVG(salary) OVER (PARTITION BY department_id) AS dept_avg_salary
FROM employees;
-- Result:
-- EMPLOYEE_ID | FIRST_NAME | SALARY | DEPARTMENT_ID | DEPT_AVG_SALARY
-- ------------|------------|--------|---------------|----------------
-- 200 | Jennifer | 4400 | 10 | 4400
-- 201 | Michael | 13000 | 20 | 9500
-- 202 | Pat | 6000 | 20 | 9500
-- 103 | Alexander | 9000 | 60 | 5760
-- 104 | Bruce | 6000 | 60 | 5760
-- (all rows preserved β each row shows the average for its OWN department)
The "window" is the set of rows that each function looks at. For row belonging to department 60, the window for that AVG is all rows in department 60.
The OVER() Clause β Anatomy
Every window function uses OVER(). The OVER clause defines the window:
function_name() OVER (
[PARTITION BY column1, column2, ...] -- divide rows into groups
[ORDER BY column3 [ASC|DESC], ...] -- order rows within each partition
[frame_specification] -- which subset of rows to include
)
| Clause | Purpose | Optional? |
|---|---|---|
PARTITION BY |
Divide rows into independent windows (like GROUP BY) | Yes |
ORDER BY |
Order rows within each partition | Yes (required for ranking/lag/lead/frames) |
| Frame specification | Narrow the window further (e.g., last 3 rows only) | Yes |
-- Examples of OVER() variations:
AVG(salary) OVER () -- window = entire result set
AVG(salary) OVER (PARTITION BY department_id) -- window = each dept separately
AVG(salary) OVER (ORDER BY hire_date) -- running avg in hire date order
AVG(salary) OVER (PARTITION BY department_id
ORDER BY salary
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) -- last 3 rows in dept
Ranking Functions
ROW_NUMBER() β Unique Sequential Rank
Assigns a unique integer to each row within a partition. No ties β every row gets a different number, even if values are identical.
-- Rank employees by salary within each department
SELECT first_name, last_name, salary, department_id,
ROW_NUMBER() OVER (
PARTITION BY department_id
ORDER BY salary DESC
) AS row_num
FROM employees
WHERE department_id IN (60, 80);
-- Result:
-- FIRST_NAME | LAST_NAME | SALARY | DEPT | ROW_NUM
-- -----------|-----------|--------|------|--------
-- Alexander | Hunold | 9000 | 60 | 1
-- Bruce | Ernst | 6000 | 60 | 2
-- David | Austin | 4800 | 60 | 3
-- John | Chen | 4800 | 60 | 4 β same salary as David, different num
-- Valli | Pataballa | 4800 | 60 | 5 β ROW_NUMBER is always unique
-- John | Russell | 14000 | 80 | 1 β resets for dept 80
-- Karen | Partners | 13500 | 80 | 2
RANK() β Rank with Gaps After Ties
Assigns the same rank to tied rows, but the next rank jumps over the tie (like race positions: 1st, 2nd, 2nd, 4th β there is no 3rd).
SELECT first_name, salary, department_id,
RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rnk
FROM employees
WHERE department_id = 60;
-- Result:
-- FIRST_NAME | SALARY | DEPT | RNK
-- -----------|--------|------|----
-- Alexander | 9000 | 60 | 1
-- Bruce | 6000 | 60 | 2
-- David | 4800 | 60 | 3 β tie
-- John | 4800 | 60 | 3 β tie
-- Valli | 4800 | 60 | 3 β tie
-- Diana | 4200 | 60 | 6 β jumps to 6, not 4 (gap because of 3-way tie)
DENSE_RANK() β Rank Without Gaps
Like RANK(), but the next rank is always exactly one more β no skipping:
SELECT first_name, salary, department_id,
DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS dense_rnk
FROM employees
WHERE department_id = 60;
-- Result:
-- FIRST_NAME | SALARY | DEPT | DENSE_RNK
-- -----------|--------|------|----------
-- Alexander | 9000 | 60 | 1
-- Bruce | 6000 | 60 | 2
-- David | 4800 | 60 | 3 β tie
-- John | 4800 | 60 | 3 β tie
-- Valli | 4800 | 60 | 3 β tie
-- Diana | 4200 | 60 | 4 β next rank is 4, not 6 (no gap!)
Ranking Function Comparison
| Function | Ties | Next rank after tie |
|---|---|---|
ROW_NUMBER() |
All unique | N/A (no ties) |
RANK() |
Same rank | Jumps (1, 2, 2, 4) |
DENSE_RANK() |
Same rank | Consecutive (1, 2, 2, 3) |
NTILE(n) β Divide Into Buckets
Divides rows into n equal-sized groups and assigns a bucket number (1 through n) to each row. Useful for percentile analysis:
-- Divide employees into 4 salary quartiles
SELECT first_name, salary,
NTILE(4) OVER (ORDER BY salary) AS salary_quartile
FROM employees
ORDER BY salary;
-- Result:
-- FIRST_NAME | SALARY | SALARY_QUARTILE
-- -----------|--------|----------------
-- Britney | 3100 | 1 β bottom 25%
-- James | 3200 | 1
-- ... | | 2 β 25-50%
-- ... | | 3 β 50-75%
-- Steven | 24000 | 4 β top 25%
-- Neena | 17000 | 4
PERCENT_RANK() β Relative Rank as Percentage
Returns the relative rank of a row within its partition as a value between 0 and 1:
SELECT first_name, salary,
ROUND(PERCENT_RANK() OVER (ORDER BY salary), 3) AS pct_rank
FROM employees;
-- A PERCENT_RANK of 0.75 means 75% of rows have a lower salary
LAG and LEAD β Accessing Other Rows
LAG and LEAD let you look at values from previous or future rows without a self-join. This is invaluable for comparing a row to the row before or after it.
LAG(column, n, default) β Look Backward
LAG(column, n, default)
-- column: which column to look at
-- n: how many rows back (default: 1)
-- default: value if no previous row exists (default: NULL)
-- Compare each employee's salary to the previous employee (ordered by salary)
SELECT first_name, last_name, salary,
LAG(salary, 1, 0) OVER (ORDER BY salary) AS prev_salary,
salary - LAG(salary, 1, 0) OVER (ORDER BY salary) AS diff_from_prev
FROM employees
ORDER BY salary;
-- Result:
-- FIRST_NAME | SALARY | PREV_SALARY | DIFF_FROM_PREV
-- -----------|--------|-------------|---------------
-- Britney | 3100 | 0 | 3100
-- James | 3200 | 3100 | 100
-- TJ | 3600 | 3200 | 400
-- Kevin | 3800 | 3600 | 200
Practical use: Month-over-month comparison
-- Compare monthly sales to previous month's sales
SELECT year,
month,
total_sales,
LAG(total_sales, 1) OVER (ORDER BY year, month) AS prev_month_sales,
ROUND(
100.0 * (total_sales - LAG(total_sales, 1) OVER (ORDER BY year, month))
/ LAG(total_sales, 1) OVER (ORDER BY year, month),
1
) AS pct_change
FROM monthly_sales_summary;
-- Result:
-- YEAR | MONTH | TOTAL_SALES | PREV_MONTH | PCT_CHANGE
-- -----|-------|-------------|------------|----------
-- 2024 | 1 | 150000 | (null) | (null)
-- 2024 | 2 | 162000 | 150000 | 8.0
-- 2024 | 3 | 158000 | 162000 | -2.5
LEAD(column, n, default) β Look Forward
-- Show each employee's hire date and the NEXT employee's hire date
SELECT first_name, last_name, hire_date,
LEAD(hire_date, 1) OVER (ORDER BY hire_date) AS next_hire_date
FROM employees
ORDER BY hire_date;
-- Result:
-- FIRST_NAME | HIRE_DATE | NEXT_HIRE_DATE
-- -----------|------------|---------------
-- Jennifer | 1987-09-17 | 1989-11-17
-- Michael | 1989-11-17 | 1990-01-03
-- Pat | 1990-01-03 | 1991-09-21
Practical use: Find next order date per customer
-- For each order, show when the customer placed their NEXT order
SELECT customer_id,
order_id,
order_date,
LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order_date,
LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) - order_date
AS days_until_next_order
FROM orders;
Aggregate Window Functions β Running Totals and Moving Averages
You can use SUM, AVG, COUNT, MIN, and MAX as window functions β without collapsing rows.
Running Total
-- Running total of salary by hire date (cumulative)
SELECT first_name, hire_date, salary,
SUM(salary) OVER (ORDER BY hire_date) AS running_total
FROM employees
ORDER BY hire_date;
-- Result:
-- FIRST_NAME | HIRE_DATE | SALARY | RUNNING_TOTAL
-- -----------|------------|--------|---------------
-- Jennifer | 1987-09-17 | 4400 | 4400
-- Michael | 1989-11-17 | 13000 | 17400
-- Pat | 1990-01-03 | 6000 | 23400
-- Susan | 1991-09-21 | 6500 | 29900
Running Total Reset Per Partition
-- Running salary total that resets for each department
SELECT first_name, department_id, salary,
SUM(salary) OVER (
PARTITION BY department_id
ORDER BY salary
) AS dept_running_total
FROM employees
ORDER BY department_id, salary;
Running COUNT
-- Running headcount by hire date
SELECT first_name, hire_date,
COUNT(*) OVER (ORDER BY hire_date) AS cumulative_headcount
FROM employees
ORDER BY hire_date;
Moving Average (3-Month Window)
For moving averages, we need the frame specification β see the next section.
Frame Specification β Controlling the Window Size
The frame defines exactly which rows are included in the window for each row's calculation. This is what makes running totals, moving averages, and sliding windows possible.
Frame Syntax
ROWS BETWEEN start AND end
RANGE BETWEEN start AND end
Where start and end can be:
| Keyword | Meaning |
|---|---|
UNBOUNDED PRECEDING |
First row of the partition |
n PRECEDING |
n rows before the current row |
CURRENT ROW |
The current row |
n FOLLOWING |
n rows after the current row |
UNBOUNDED FOLLOWING |
Last row of the partition |
Common Frame Patterns
-- Running total (from the first row to the current row)
SUM(salary) OVER (ORDER BY hire_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
-- Entire partition total (default when no ORDER BY in aggregate window function)
SUM(salary) OVER (PARTITION BY department_id)
-- 3-row moving average (previous, current, next)
AVG(salary) OVER (ORDER BY salary ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)
-- Last 3 rows only (current + 2 preceding)
AVG(salary) OVER (ORDER BY hire_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
3-Month Moving Average Example
-- Monthly revenue with 3-month moving average
SELECT year,
month,
revenue,
ROUND(
AVG(revenue) OVER (
ORDER BY year, month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2
) AS moving_avg_3m
FROM monthly_revenue;
-- Result:
-- YEAR | MONTH | REVENUE | MOVING_AVG_3M
-- -----|-------|---------|---------------
-- 2024 | 1 | 100000 | 100000.00 β only 1 row so far
-- 2024 | 2 | 120000 | 110000.00 β avg of months 1-2
-- 2024 | 3 | 110000 | 110000.00 β avg of months 1-3
-- 2024 | 4 | 130000 | 120000.00 β avg of months 2-4 (slides forward)
-- 2024 | 5 | 115000 | 118333.33 β avg of months 3-5
ROWS vs RANGE
ROWScounts actual row positions (physical rows). Use this for most cases.RANGEincludes all rows with the same ORDER BY value as the current row. This can include more rows than expected when there are ties.
-- ROWS: precise β exactly the 2 preceding rows + current
AVG(salary) OVER (ORDER BY salary ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)
-- RANGE: logical β includes all rows with salary <= current salary's value
-- If multiple employees have the same salary, they all count as "current row"
AVG(salary) OVER (ORDER BY salary RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
ROWS β it's precise and predictable. Use RANGE when you want to include all rows with the same value as the "current row" in the calculation, such as in financial calculations where all transactions on the same date should be treated equally.
FIRST_VALUE, LAST_VALUE, NTH_VALUE
These functions return the value of a specific row within the window frame.
FIRST_VALUE() β First Row in Window
-- For each employee, show the lowest salary in their department
SELECT first_name, salary, department_id,
FIRST_VALUE(salary) OVER (
PARTITION BY department_id
ORDER BY salary ASC
) AS dept_min_salary
FROM employees;
-- Each row shows the minimum salary in its department
-- (FIRST_VALUE with ORDER BY ASC = minimum)
LAST_VALUE() β Last Row in Window (Watch Out for Frames!)
-- LAST_VALUE pitfall: by default, the frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
-- This means LAST_VALUE gives the CURRENT ROW's value, not the partition's last row!
-- WRONG: doesn't give partition maximum
SELECT salary, LAST_VALUE(salary) OVER (PARTITION BY department_id ORDER BY salary) AS wrong;
-- CORRECT: must expand the frame to include all rows
SELECT first_name, salary, department_id,
LAST_VALUE(salary) OVER (
PARTITION BY department_id
ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS dept_max_salary
FROM employees;
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. Without this, it returns the value of the current row (not the last row of the partition), which is rarely what you want.
NTH_VALUE() β Nth Row in Window
-- Get the 2nd highest salary in each department
SELECT first_name, salary, department_id,
NTH_VALUE(salary, 2) OVER (
PARTITION BY department_id
ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS second_highest_salary
FROM employees;
Practical Window Function Examples
Example 1: Rank Employees Per Department
-- Top 3 earners in each department (classic interview question)
SELECT first_name, last_name, salary, department_id
FROM (
SELECT first_name, last_name, salary, department_id,
RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS salary_rank
FROM employees
)
WHERE salary_rank <= 3
ORDER BY department_id, salary_rank;
Example 2: Year-over-Year Revenue Comparison
-- Annual revenue with year-over-year growth percentage
SELECT year,
total_revenue,
LAG(total_revenue, 1) OVER (ORDER BY year) AS prev_year_revenue,
ROUND(
100.0 * (total_revenue - LAG(total_revenue) OVER (ORDER BY year))
/ LAG(total_revenue) OVER (ORDER BY year),
2
) AS yoy_growth_pct
FROM annual_revenue;
Example 3: Deduplicate Rows with ROW_NUMBER()
One of the most practical uses of ROW_NUMBER() is removing duplicate rows from a table:
-- Find and remove duplicate employees keeping only the one with the lowest employee_id
DELETE FROM employees
WHERE employee_id IN (
SELECT employee_id
FROM (
SELECT employee_id,
ROW_NUMBER() OVER (
PARTITION BY first_name, last_name, hire_date -- what makes a "duplicate"
ORDER BY employee_id -- keep the lowest ID
) AS rn
FROM employees
)
WHERE rn > 1 -- rn = 1 is the "keeper", rn > 1 are duplicates
);
Example 4: Running Total of Sales with Reset Per Year
SELECT order_date,
EXTRACT(YEAR FROM order_date) AS year,
amount,
SUM(amount) OVER (
PARTITION BY EXTRACT(YEAR FROM order_date) -- reset each year
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS ytd_total
FROM orders
ORDER BY order_date;
Example 5: Compare Each Employee to Company Average
-- Each employee: their salary, company avg, dept avg, difference from both
SELECT first_name,
last_name,
department_id,
salary,
ROUND(AVG(salary) OVER (), 2) AS company_avg,
ROUND(AVG(salary) OVER (PARTITION BY department_id), 2) AS dept_avg,
salary - ROUND(AVG(salary) OVER (), 2) AS diff_from_company,
salary - ROUND(AVG(salary) OVER (PARTITION BY department_id), 2) AS diff_from_dept
FROM employees
ORDER BY department_id, salary DESC;
Example 6: Find Previous and Next Order Date
-- For each order, show the previous and next order dates from the same customer
SELECT customer_id,
order_id,
order_date,
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order,
LEAD(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order
FROM orders
ORDER BY customer_id, order_date;
Window Functions vs GROUP BY β When to Use Which
| Scenario | Use GROUP BY | Use Window Function |
|---|---|---|
| You need ONE row per group | β | |
| You need aggregate + original row detail | β | |
| Running total / cumulative sum | β | |
| Ranking within a group | β | |
| Compare row to previous/next row | β | |
| Moving average | β | |
| Count of rows in group only | β | |
| Deduplicate rows | β (ROW_NUMBER) |
-- WRONG: cannot use window function alias in WHERE
SELECT first_name, salary,
RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
WHERE rnk <= 5; -- ERROR: rnk is not visible here
-- CORRECT: wrap in a subquery or CTE
SELECT * FROM (
SELECT first_name, salary,
RANK() OVER (ORDER BY salary DESC) AS rnk
FROM employees
)
WHERE rnk <= 5;
Quick Reference: Window Function Cheat Sheet
-- RANKING
ROW_NUMBER() OVER (PARTITION BY dept ORDER BY salary DESC) -- unique rank
RANK() OVER (PARTITION BY dept ORDER BY salary DESC) -- rank with gaps
DENSE_RANK() OVER (PARTITION BY dept ORDER BY salary DESC) -- rank no gaps
NTILE(4) OVER (ORDER BY salary) -- quartile buckets
-- LAG / LEAD
LAG(salary) OVER (ORDER BY hire_date) -- previous row value
LAG(salary, 2) OVER (ORDER BY hire_date) -- 2 rows back
LAG(salary, 1, 0) OVER (ORDER BY hire_date) -- default 0 if no prev
LEAD(salary) OVER (ORDER BY hire_date) -- next row value
-- AGGREGATES
SUM(salary) OVER () -- grand total (all rows)
SUM(salary) OVER (PARTITION BY dept) -- partition total
SUM(salary) OVER (ORDER BY hire_date) -- running total
AVG(salary) OVER (PARTITION BY dept ORDER BY salary
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) -- 3-row moving avg
-- FIRST / LAST / NTH
FIRST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary) -- min in partition
LAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING) -- max in partition
NTH_VALUE(salary, 2) OVER (PARTITION BY dept ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING
AND UNBOUNDED FOLLOWING) -- 2nd highest
Common Errors
| Error | Cause | Fix |
|---|---|---|
| ORA-30483 | Window functions are not allowed here β window function used in WHERE, GROUP BY, or HAVING | Move to an outer query: wrap in a subquery or CTE, then filter |
| ORA-30487 | ORDER BY not allowed here β ORDER BY clause inside an aggregate used without OVER | Ensure OVER() is present; plain aggregates (SUM(salary)) don't take ORDER BY |
| ORA-00904 | Invalid identifier in OVER clause β column referenced in PARTITION BY / ORDER BY not in scope | Check column names; OVER clause can only reference columns available to the SELECT |
| ORA-30485 | Missing ORDER BY expression in the window specification β using RANK/ROW_NUMBER without ORDER BY | Add ORDER BY inside OVER(): ROW_NUMBER() OVER (ORDER BY salary DESC) |
| Wrong frame default | Forgetting that the default frame for ordered windows is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, not the full partition |
Explicitly specify the frame: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING when you need the full partition |
| LAST_VALUE returns current row | LAST_VALUE with default frame only looks up to the current row, not the partition end | Use ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING with LAST_VALUE |
Interview Corner
ROW_NUMBER(), RANK(), and DENSE_RANK()? Give an example with ties.βΆ Show answer
All three assign a numeric rank within an ordered partition, but they handle ties differently:
| Function | Tie behaviour | Gap after tie? |
|---|---|---|
ROW_NUMBER() |
Assigns unique numbers β ties broken arbitrarily | No |
RANK() |
Tied rows get the same rank; next rank skips | Yes |
DENSE_RANK() |
Tied rows get the same rank; next rank does NOT skip | No |
SELECT last_name, salary,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn,
RANK() OVER (ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (ORDER BY salary DESC) AS dr
FROM employees
WHERE department_id = 90;
Result (if two employees share salary 17000):
| last_name | salary | rn | rnk | dr |
|---|---|---|---|---|
| King | 24000 | 1 | 1 | 1 |
| Kochhar | 17000 | 2 | 2 | 2 |
| De Haan | 17000 | 3 | 2 | 2 |
| next⦠| 4 | 4 | 3 |
Use ROW_NUMBER for pagination (unique rows needed). Use RANK / DENSE_RANK for leaderboard/medal-style rankings.
PARTITION BY in a window function differ from GROUP BY?βΆ Show answer
GROUP BY collapses rows β one output row per group. Non-grouped columns must be aggregated.
PARTITION BY in a window function performs the aggregation without collapsing rows. Every input row remains in the result, and the computed value is added as an additional column.
-- GROUP BY: 11 rows (one per department)
SELECT department_id, AVG(salary) AS dept_avg
FROM employees
GROUP BY department_id;
-- PARTITION BY: 107 rows β each employee row + their department average
SELECT employee_id, last_name, salary, department_id,
AVG(salary) OVER (PARTITION BY department_id) AS dept_avg,
salary - AVG(salary) OVER (PARTITION BY department_id) AS diff_from_avg
FROM employees;
Window functions let you compare individual rows to their group's aggregate β something impossible with plain GROUP BY.
Related Topics
- Aggregate Functions β GROUP BY aggregation that collapses rows
- Subqueries β correlated subqueries that window functions often replace
- CTEs & Procedures β CTEs make multi-step window function queries readable
- Performance β window functions can be expensive on large tables without good partition pruning