powershelldba.de

Window Functions: The Complete Guide to SQL Server's Most Powerful Feature

Window functions solve problems that previously required cursors, self-joins, or complex GROUP BY logic. They let you aggregate, rank, and compare rows without collapsing result sets. Once you master window functions, you'll wonder how you ever wrote SQL without them. This guide covers everything: OVER clause, PARTITION BY, ORDER BY, frames, ranking, analytics, and real-world patterns.

What Are Window Functions?

A window function performs a calculation across a set of rows related to the current row. Unlike GROUP BY, which collapses rows, window functions return a value for EVERY row.

-- GROUP BY (collapses rows):
SELECT Department, SUM(Salary)
FROM dbo.Employees
GROUP BY Department;
-- Result: 1 row per department

-- Window Function (keeps all rows):
SELECT
  EmployeeID,
  Department,
  Salary,
  SUM(Salary) OVER (PARTITION BY Department) AS DepartmentTotal
FROM dbo.Employees;
-- Result: ALL employees, plus department total for each

-- Every employee gets the department total calculated!

The OVER Clause: Core Syntax

FunctionName() OVER (
  [PARTITION BY column(s)]
  [ORDER BY column(s) ASC|DESC]
  [ROWS|RANGE frame_specification]
)

Example:
SUM(Salary) OVER (
  PARTITION BY Department
  ORDER BY Salary DESC
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)

Breaking it down:
• PARTITION BY: Divide rows into groups (like GROUP BY)
• ORDER BY: Sort within each partition (for ranking, running totals)
• ROWS/RANGE: Define which rows to include (frame)

PARTITION BY: Divide Into Groups

-- No PARTITION BY (entire dataset is one window):
SELECT
  EmployeeID,
  Salary,
  SUM(Salary) OVER () AS GrandTotal
FROM dbo.Employees;
-- All employees' sum = one total for all 1000 rows

-- With PARTITION BY:
SELECT
  EmployeeID,
  Department,
  Salary,
  SUM(Salary) OVER (PARTITION BY Department) AS DepartmentTotal,
  COUNT(*) OVER (PARTITION BY Department) AS DepartmentCount
FROM dbo.Employees;
-- Each department gets its own total and count
// Employees in Sales see Sales total
// Employees in Engineering see Engineering total

Ranking Functions

Function Purpose Behavior with Ties Example
ROW_NUMBER() Unique row number 1, 2, 3, 4 (even if tied) Distinct ordering
RANK() Ranking with gaps 1, 2, 2, 4 (skips 3) Competition ranking
DENSE_RANK() Ranking no gaps 1, 2, 2, 3 (no skips) Scoreboard ranking
NTILE(n) Divide into n buckets Quartiles, deciles Top 25%, next 25%

ROW_NUMBER: Unique Sequential Number

-- Get top 3 employees per department by salary
SELECT
  Department,
  EmployeeID,
  Salary,
  ROW_NUMBER() OVER (PARTITION BY Department ORDER BY Salary DESC) AS RankInDept
FROM dbo.Employees
WHERE ROW_NUMBER() OVER (PARTITION BY Department ORDER BY Salary DESC) <= 3;
-- ERROR! Can't use window function in WHERE

-- Correct: Use CTE or subquery
SELECT *
FROM (
  SELECT
    Department,
    EmployeeID,
    Salary,
    ROW_NUMBER() OVER (PARTITION BY Department ORDER BY Salary DESC) AS RankInDept
  FROM dbo.Employees
) ranked
WHERE RankInDept <= 3;

-- Result: Top 3 per department

RANK vs. DENSE_RANK with Ties

-- Data:
Score: 100, 90, 90, 85

-- RANK():
ROW_NUMBER: 1, 2, 3, 4
RANK:       1, 2, 2, 4  (skips 3)

-- DENSE_RANK():
DENSE_RANK: 1, 2, 2, 3  (no skips)

-- RANK: Competition ranking (gold, silver, silver, bronze skip to bronze)
-- DENSE_RANK: Scoreboard ranking (1st, 2nd, 2nd, 3rd)

Aggregate Functions as Window Functions

-- SUM, AVG, COUNT, MIN, MAX can be window functions!

SELECT
  EmployeeID,
  Month,
  Sales,
  SUM(Sales) OVER (PARTITION BY EmployeeID ORDER BY Month) AS RunningTotal,
  AVG(Sales) OVER (PARTITION BY EmployeeID) AS AvgSales,
  COUNT(*) OVER (PARTITION BY EmployeeID) AS SalesCount,
  MAX(Sales) OVER (PARTITION BY EmployeeID) AS BestMonth
FROM dbo.MonthlySales;

-- Running total: Sales so far this year (per employee)
-- Average: Average all-time sales (per employee)
-- Max: Best sales month (per employee)

Analytical Functions: LAG, LEAD, FIRST_VALUE, LAST_VALUE

LAG and LEAD: Access Previous/Next Rows

-- Year-over-year comparison
SELECT
  Year,
  Revenue,
  LAG(Revenue) OVER (ORDER BY Year) AS PreviousYear,
  Revenue - LAG(Revenue) OVER (ORDER BY Year) AS YearOverYearChange,
  LEAD(Revenue) OVER (ORDER BY Year) AS NextYear
FROM dbo.AnnualRevenue
ORDER BY Year;

-- LAG: Previous row value
-- LEAD: Next row value
-- Useful for: Comparing rows, detecting changes

FIRST_VALUE and LAST_VALUE: Get First/Last in Window

-- Get first and last order per customer
SELECT
  CustomerID,
  OrderDate,
  Amount,
  FIRST_VALUE(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS FirstOrder,
  LAST_VALUE(OrderDate) OVER (
    PARTITION BY CustomerID
    ORDER BY OrderDate
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING  -- Important!
  ) AS LastOrder
FROM dbo.Orders;

-- FIRST_VALUE: First order date (per customer)
-- LAST_VALUE: Last order date (per customer)
-- NOTE: ROWS BETWEEN clause needed for LAST_VALUE (see Frames section)

Frames: Controlling the Window Boundaries

By default, when using ORDER BY, the frame is "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW" (all rows from start to current). You can change this with explicit frame specifications.

-- Default frame (when ORDER BY is present):
SUM(Sales) OVER (ORDER BY Date)
-- = ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
-- Running total up to current row

-- Running total last 3 rows:
SUM(Sales) OVER (
  ORDER BY Date
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
)

-- All rows in window:
SUM(Sales) OVER (
  ORDER BY Date
  ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

-- Frame specifications:
UNBOUNDED PRECEDING: Start of partition
N PRECEDING: N rows back
CURRENT ROW: Current row
N FOLLOWING: N rows forward
UNBOUNDED FOLLOWING: End of partition

Running Totals: The Classic Example

-- Monthly sales with running totals
SELECT
  Month,
  Sales,
  SUM(Sales) OVER (ORDER BY Month) AS RunningTotal,
  SUM(Sales) OVER (ORDER BY Month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS Last3Months,
  AVG(Sales) OVER (ORDER BY Month ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS Avg3Months
FROM dbo.MonthlySales;

-- Results:
Jan:  1000  |  1000  |  1000   |  1000
Feb:  1500  |  2500  |  2500   |  1250
Mar:  2000  |  4500  |  4500   |  1500
Apr:  1800  |  6300  |  5800   |  1933
May:  2200  |  8500  |  6000   |  2000

-- Running total cumulative (classic)
-- Last 3 months: Moving window
-- Avg 3 months: Moving average

Performance Tips

-- ✓ FAST: Filtered subquery
SELECT *
FROM (
  SELECT
    EmployeeID,
    Department,
    Salary,
    ROW_NUMBER() OVER (PARTITION BY Department ORDER BY Salary DESC) AS RankInDept
  FROM dbo.Employees
  WHERE Active = 1  -- Filter early!
) ranked
WHERE RankInDept <= 3;

-- ✗ SLOW: Complex window function without filter
SELECT
  EmployeeID,
  Salary,
  ROW_NUMBER() OVER (ORDER BY Salary DESC) AS RankAllEmployees  -- No partition
FROM dbo.Employees;
-- All 1M employees ranked globally
// Better: Add PARTITION BY if possible

-- ✓ TIP: Add indexes on partition/order columns
CREATE INDEX idx_Employees_DeptSalary
ON dbo.Employees(Department, Salary DESC);
-- Helps window function execution

Common Mistakes

Mistake 1: Using Window Function in WHERE Clause

-- ✗ ERROR:
SELECT EmployeeID, Salary
FROM dbo.Employees
WHERE ROW_NUMBER() OVER (PARTITION BY Department ORDER BY Salary DESC) <= 3;
-- ERROR: Window functions not allowed in WHERE

-- ✓ CORRECT (use CTE or subquery):
WITH ranked AS (
  SELECT
    EmployeeID,
    Department,
    Salary,
    ROW_NUMBER() OVER (PARTITION BY Department ORDER BY Salary DESC) AS RankInDept
  FROM dbo.Employees
)
SELECT EmployeeID, Salary
FROM ranked
WHERE RankInDept <= 3;

Mistake 2: Forgetting ROWS BETWEEN for LAST_VALUE

-- ✗ WRONG (gets current row, not last):
LAST_VALUE(OrderDate) OVER (PARTITION BY CustomerID ORDER BY OrderDate)
-- Default: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
// Returns current row, not last! Wrong!

-- ✓ CORRECT (get actual last row):
LAST_VALUE(OrderDate) OVER (
  PARTITION BY CustomerID
  ORDER BY OrderDate
  ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
// Now returns truly last row

Mistake 3: Confusing ROW_NUMBER, RANK, DENSE_RANK

-- Same data, different results:
Score: 100, 90, 90, 85

ROW_NUMBER():   1, 2, 3, 4  (always unique)
RANK():         1, 2, 2, 4  (gaps when tied)
DENSE_RANK():   1, 2, 2, 3  (no gaps)

-- Use ROW_NUMBER: Get distinct rows (top N)
-- Use RANK: Competition scoring (gold, silver, silver, bronze)
-- Use DENSE_RANK: Scoreboard (1st, 2nd, 2nd, 3rd)

Best Practices

The Bottom Line

Window functions are SQL Server's most powerful feature for analytics, ranking, and comparing rows. They eliminate the need for cursors, self-joins, and complex GROUP BY logic.

Master the OVER clause: PARTITION BY divides data, ORDER BY sorts, and ROWS BETWEEN defines boundaries. Use window functions for running totals, top N per group, ranking, and year-over-year comparisons.

Once you start using window functions, you'll realize how many problems become trivial: "Show me top 3 products per category"—one query. "Running total of sales"—one query. "Rank employees by department"—one query. Window functions are the secret to writing elegant, efficient SQL.

← Back to Blog