Skip to content

Bits of .NET

Daily micro-tips for C#, SQL, performance, and scalable backend engineering.

  • Asp.Net Core
  • C#
  • SQL
  • JavaScript
  • CSS
  • About
  • ErcanOPAK.com
  • No Access
  • Privacy Policy
SQL

SQL: Use Window Functions to Calculate Running Totals Without Self-Joins

- 03.02.26 - ErcanOPAK

Need running totals, rankings, or moving averages? Window functions do it in one query without complex self-joins or cursors.

The Old Painful Way – Self-Join:

-- Calculate running total of sales
SELECT 
    o1.OrderDate,
    o1.Amount,
    (SELECT SUM(o2.Amount) 
     FROM Orders o2 
     WHERE o2.OrderDate <= o1.OrderDate) AS RunningTotal
FROM Orders o1
ORDER BY o1.OrderDate;

-- Problems:
-- - Correlated subquery runs for EVERY row
-- - O(n²) performance (100K rows = 10 billion operations)
-- - Takes minutes on large tables

The Window Function Way:

-- Same result, 100x faster
SELECT 
    OrderDate,
    Amount,
    SUM(Amount) OVER (ORDER BY OrderDate) AS RunningTotal
FROM Orders
ORDER BY OrderDate;

-- Performance: O(n) instead of O(n²)
-- 100K rows: 2 seconds vs 3 minutes

How Window Functions Work:

-- Syntax:
-- FUNCTION() OVER (
--     PARTITION BY column   -- Optional: Group by
--     ORDER BY column       -- Optional: Order within partition
--     ROWS/RANGE clause     -- Optional: Frame specification
-- )

-- Without PARTITION BY: Operates on entire result set
-- With PARTITION BY: Resets calculation per group
-- ORDER BY: Determines calculation order

Running Total Examples:

-- Simple running total
SELECT 
    OrderDate,
    Amount,
    SUM(Amount) OVER (ORDER BY OrderDate) AS RunningTotal
FROM Orders;

-- Running total per customer
SELECT 
    CustomerId,
    OrderDate,
    Amount,
    SUM(Amount) OVER (
        PARTITION BY CustomerId 
        ORDER BY OrderDate
    ) AS CustomerRunningTotal
FROM Orders;

-- Running average
SELECT 
    OrderDate,
    Amount,
    AVG(Amount) OVER (ORDER BY OrderDate) AS RunningAverage
FROM Orders;

Ranking Functions:

-- ROW_NUMBER: Unique sequential number
SELECT 
    ProductName,
    Sales,
    ROW_NUMBER() OVER (ORDER BY Sales DESC) AS RowNum
FROM Products;
-- Output: 1, 2, 3, 4, 5... (no ties)

-- RANK: Same rank for ties, skips numbers
SELECT 
    ProductName,
    Sales,
    RANK() OVER (ORDER BY Sales DESC) AS Rank
FROM Products;
-- Output: 1, 2, 2, 4, 5... (ties get same rank, next rank skips)

-- DENSE_RANK: Same rank for ties, doesn't skip
SELECT 
    ProductName,
    Sales,
    DENSE_RANK() OVER (ORDER BY Sales DESC) AS DenseRank
FROM Products;
-- Output: 1, 2, 2, 3, 4... (ties get same rank, next rank doesn't skip)

-- Example with all three:
-- Sales: 100, 90, 90, 80, 70
-- ROW_NUMBER: 1, 2, 3, 4, 5
-- RANK: 1, 2, 2, 4, 5
-- DENSE_RANK: 1, 2, 2, 3, 4

Top N Per Group:

-- Get top 3 products per category
WITH RankedProducts AS (
    SELECT 
        CategoryId,
        ProductName,
        Sales,
        ROW_NUMBER() OVER (
            PARTITION BY CategoryId 
            ORDER BY Sales DESC
        ) AS Rank
    FROM Products
)
SELECT 
    CategoryId,
    ProductName,
    Sales
FROM RankedProducts
WHERE Rank <= 3
ORDER BY CategoryId, Rank;

-- Each category gets its top 3 products

Moving Average (Sliding Window):

-- 7-day moving average of sales
SELECT 
    OrderDate,
    Amount,
    AVG(Amount) OVER (
        ORDER BY OrderDate
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS MovingAvg7Day
FROM Orders;

-- Explanation:
-- ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
-- = Current row + 6 rows before = 7 total rows
-- Calculates average of those 7 rows

-- 30-day moving average
SELECT 
    OrderDate,
    Amount,
    AVG(Amount) OVER (
        ORDER BY OrderDate
        ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
    ) AS MovingAvg30Day
FROM Orders;

Lead and Lag (Access Previous/Next Row):

-- Compare current month to previous month
SELECT 
    Month,
    Revenue,
    LAG(Revenue) OVER (ORDER BY Month) AS PreviousMonthRevenue,
    Revenue - LAG(Revenue) OVER (ORDER BY Month) AS MonthOverMonthChange
FROM MonthlySales;

-- Get next row value
SELECT 
    OrderDate,
    Status,
    LEAD(Status) OVER (ORDER BY OrderDate) AS NextStatus
FROM Orders;

-- Access row 2 positions ahead
SELECT 
    Month,
    Revenue,
    LEAD(Revenue, 2) OVER (ORDER BY Month) AS TwoMonthsAhead
FROM MonthlySales;

First and Last Values:

-- First order per customer
SELECT 
    CustomerId,
    OrderDate,
    Amount,
    FIRST_VALUE(OrderDate) OVER (
        PARTITION BY CustomerId 
        ORDER BY OrderDate
    ) AS FirstOrderDate
FROM Orders;

-- Last order per customer
SELECT 
    CustomerId,
    OrderDate,
    Amount,
    LAST_VALUE(OrderDate) OVER (
        PARTITION BY CustomerId 
        ORDER BY OrderDate
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS LastOrderDate
FROM Orders;

-- Note: LAST_VALUE needs frame specification
-- Otherwise it only sees rows up to current row

Percentage of Total:

-- Each product's percentage of total sales
SELECT 
    ProductName,
    Sales,
    CAST(Sales * 100.0 / SUM(Sales) OVER () AS DECIMAL(5,2)) AS PercentOfTotal
FROM Products;

-- Per category percentage
SELECT 
    CategoryId,
    ProductName,
    Sales,
    CAST(Sales * 100.0 / SUM(Sales) OVER (PARTITION BY CategoryId) AS DECIMAL(5,2)) AS PercentOfCategory
FROM Products;

Performance Tips:

-- ✅ Index columns in ORDER BY and PARTITION BY
CREATE INDEX IX_Orders_Date ON Orders(OrderDate);
CREATE INDEX IX_Orders_Customer_Date ON Orders(CustomerId, OrderDate);

-- ✅ Filter data before window function
SELECT 
    OrderDate,
    Amount,
    SUM(Amount) OVER (ORDER BY OrderDate) AS RunningTotal
FROM Orders
WHERE OrderDate >= '2024-01-01';  -- Filter first

-- ❌ Don't filter window function results unnecessarily
-- Window function must calculate all rows first

Related posts:

sp_help for SQL Server Management Studio

SQL COUNT(*) Isn’t Always Cheap

SQL Deadlocks Appear Under Load

Post Views: 4

Post navigation

.NET Core: Use Minimal APIs to Create Lightweight Endpoints Without Controllers
SQL: Use MERGE to Upsert (Insert or Update) in One Statement

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

February 2026
M T W T F S S
 1
2345678
9101112131415
16171819202122
232425262728  
« Jan    

Most Viewed Posts

  • Get the User Name and Domain Name from an Email Address in SQL (935)
  • How to add default value for Entity Framework migrations for DateTime and Bool (832)
  • Get the First and Last Word from a String or Sentence in SQL (826)
  • How to select distinct rows in a datatable in C# (799)
  • How to make theater mode the default for Youtube (716)
  • Add Constraint to SQL Table to ensure email contains @ (574)
  • How to enable, disable and check if Service Broker is enabled on a database in SQL Server (553)
  • Average of all values in a column that are not zero in SQL (520)
  • How to use Map Mode for Vertical Scroll Mode in Visual Studio (474)
  • Find numbers with more than two decimal places in SQL (436)

Recent Posts

  • C#: Use MemoryPack for 10x Faster Serialization than JSON
  • C#: Use params ReadOnlySpan for Allocation-Free Variable Arguments
  • C#: Use ObjectPool for Reusing Expensive Objects
  • C#: Use Lazy for Expensive Object Initialization
  • SQL: Use STRING_AGG to Concatenate Rows into Comma-Separated List
  • SQL: Use Filtered Indexes to Index Only Subset of Rows
  • .NET Core: Use Result Pattern to Avoid Exceptions for Expected Errors
  • .NET Core: Use IOptions Pattern for Strongly-Typed Configuration
  • Git: Use .gitattributes to Handle Line Endings Across OS
  • Git: Use git notes to Add Comments to Commits Without Changing History

Most Viewed Posts

  • Get the User Name and Domain Name from an Email Address in SQL (935)
  • How to add default value for Entity Framework migrations for DateTime and Bool (832)
  • Get the First and Last Word from a String or Sentence in SQL (826)
  • How to select distinct rows in a datatable in C# (799)
  • How to make theater mode the default for Youtube (716)

Recent Posts

  • C#: Use MemoryPack for 10x Faster Serialization than JSON
  • C#: Use params ReadOnlySpan for Allocation-Free Variable Arguments
  • C#: Use ObjectPool for Reusing Expensive Objects
  • C#: Use Lazy for Expensive Object Initialization
  • SQL: Use STRING_AGG to Concatenate Rows into Comma-Separated List

Social

  • ErcanOPAK.com
  • GoodReads
  • LetterBoxD
  • Linkedin
  • The Blog
  • Twitter
© 2026 Bits of .NET | Built with Xblog Plus free WordPress theme by wpthemespace.com