Running Totals and Moving Averages in SQL Server
Window FRAME = which rows around the current row are included:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -> running total
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -> 3-row moving window
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING -> centered window
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING -> "remaining" total
ROWS = physical rows.
RANGE = rows with the same ORDER BY value are treated as one (ties grouped). DEFAULT frame when ORDER BY is present = RANGE UNBOUNDED PRECEDING (slower, tie surprises!)
Run the sample database script first. Every query below was run on that data, and the Output under each query is the real result.
USE SqlLearningDb;
GO
Topic table: BankTransactions
DROP TABLE IF EXISTS dbo.BankTransactions;
CREATE TABLE dbo.BankTransactions
(
TxnID INT IDENTITY(1,1) PRIMARY KEY,
AccountNo VARCHAR(10) NOT NULL,
TxnDate DATE NOT NULL,
TxnType VARCHAR(10) NOT NULL CHECK (TxnType IN ('Credit', 'Debit')),
Amount DECIMAL(12,2) NOT NULL,
Remarks VARCHAR(50) NULL
);
INSERT INTO dbo.BankTransactions (AccountNo, TxnDate, TxnType, Amount, Remarks) VALUES
('ACC1001', '2025-01-01', 'Credit', 50000, 'Opening deposit'),
('ACC1001', '2025-01-05', 'Debit', 5000, 'ATM'),
('ACC1001', '2025-01-05', 'Debit', 1200, 'Groceries'), -- same date (tie)
('ACC1001', '2025-01-10', 'Debit', 15000, 'Rent'),
('ACC1001', '2025-01-31', 'Credit', 60000, 'Salary'),
('ACC1001', '2025-02-03', 'Debit', 20000, 'EMI'),
('ACC1001', '2025-02-14', 'Debit', 3500, 'Dinner'),
('ACC1001', '2025-02-28', 'Credit', 60000, 'Salary'),
('ACC2002', '2025-01-02', 'Credit', 20000, 'Opening deposit'),
('ACC2002', '2025-01-15', 'Debit', 12000, 'Laptop EMI'),
('ACC2002', '2025-01-20', 'Debit', 9000, 'Travel'), -- goes negative!
('ACC2002', '2025-01-31', 'Credit', 35000, 'Salary'),
('ACC2002', '2025-02-10', 'Debit', 4000, 'Shopping'),
('ACC2002', '2025-02-28', 'Credit', 35000, 'Salary');
GO
Part A: Running Totals
Running balance per account (credit = +, debit = -)
SELECT AccountNo, TxnDate, TxnType, Amount,
SUM(CASE WHEN TxnType = 'Credit' THEN Amount ELSE -Amount END)
OVER (PARTITION BY AccountNo ORDER BY TxnDate, TxnID
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS RunningBalance
FROM dbo.BankTransactions
ORDER BY AccountNo, TxnDate, TxnID;
Output
| AccountNo | TxnDate | TxnType | Amount | RunningBalance |
|---|---|---|---|---|
| ACC1001 | 2025-01-01 | Credit | 50000.00 | 50000.00 |
| ACC1001 | 2025-01-05 | Debit | 5000.00 | 45000.00 |
| ACC1001 | 2025-01-05 | Debit | 1200.00 | 43800.00 |
| ACC1001 | 2025-01-10 | Debit | 15000.00 | 28800.00 |
| ACC1001 | 2025-01-31 | Credit | 60000.00 | 88800.00 |
| ACC1001 | 2025-02-03 | Debit | 20000.00 | 68800.00 |
| ACC1001 | 2025-02-14 | Debit | 3500.00 | 65300.00 |
| ACC1001 | 2025-02-28 | Credit | 60000.00 | 125300.00 |
| ACC2002 | 2025-01-02 | Credit | 20000.00 | 20000.00 |
| ACC2002 | 2025-01-15 | Debit | 12000.00 | 8000.00 |
| ACC2002 | 2025-01-20 | Debit | 9000.00 | -1000.00 |
| ACC2002 | 2025-01-31 | Credit | 35000.00 | 34000.00 |
| ACC2002 | 2025-02-10 | Debit | 4000.00 | 30000.00 |
| ACC2002 | 2025-02-28 | Credit | 35000.00 | 65000.00 |
Find the moments when an account balance went Negative
WITH b AS
(
SELECT AccountNo, TxnDate, Remarks,
SUM(CASE WHEN TxnType = 'Credit' THEN Amount ELSE -Amount END)
OVER (PARTITION BY AccountNo ORDER BY TxnDate, TxnID ROWS UNBOUNDED PRECEDING) AS Balance
FROM dbo.BankTransactions
)
SELECT * FROM b WHERE Balance < 0;
Output
| AccountNo | TxnDate | Remarks | Balance |
|---|---|---|---|
| ACC2002 | 2025-01-20 | Travel | -1000.00 |
Rows vs Range: look at 2025-01-05 (two rows with the same date)
SELECT AccountNo, TxnDate, Amount,
SUM(Amount) OVER (PARTITION BY AccountNo ORDER BY TxnDate ROWS UNBOUNDED PRECEDING) AS RunningRows,
SUM(Amount) OVER (PARTITION BY AccountNo ORDER BY TxnDate RANGE UNBOUNDED PRECEDING) AS RunningRange,
SUM(Amount) OVER (PARTITION BY AccountNo ORDER BY TxnDate) AS RunningDefault -- = RANGE
FROM dbo.BankTransactions
WHERE AccountNo = 'ACC1001' AND TxnType = 'Debit';
Output
| AccountNo | TxnDate | Amount | RunningRows | RunningRange | RunningDefault |
|---|---|---|---|---|---|
| ACC1001 | 2025-01-05 | 5000.00 | 5000.00 | 6200.00 | 6200.00 |
| ACC1001 | 2025-01-05 | 1200.00 | 6200.00 | 6200.00 | 6200.00 |
| ACC1001 | 2025-01-10 | 15000.00 | 21200.00 | 21200.00 | 21200.00 |
| ACC1001 | 2025-02-03 | 20000.00 | 41200.00 | 41200.00 | 41200.00 |
| ACC1001 | 2025-02-14 | 3500.00 | 44700.00 | 44700.00 | 44700.00 |
Year-to-date (ytd) and month-to-date (mtd) sales
WITH Daily AS
(
SELECT SaleDate, SUM(TotalAmount) AS DayRevenue FROM dbo.Sales GROUP BY SaleDate
)
SELECT SaleDate, DayRevenue,
SUM(DayRevenue) OVER (PARTITION BY YEAR(SaleDate) ORDER BY SaleDate ROWS UNBOUNDED PRECEDING) AS YTD,
SUM(DayRevenue) OVER (PARTITION BY YEAR(SaleDate), MONTH(SaleDate) ORDER BY SaleDate ROWS UNBOUNDED PRECEDING) AS MTD
FROM Daily
ORDER BY SaleDate;
Output
| SaleDate | DayRevenue | YTD | MTD |
|---|---|---|---|
| 2025-01-02 | 245400.00 | 245400.00 | 245400.00 |
| 2025-01-04 | 1800.00 | 247200.00 | 247200.00 |
| 2025-01-05 | 36000.00 | 283200.00 | 283200.00 |
| 2025-01-06 | 261000.00 | 544200.00 | 544200.00 |
| 2025-01-07 | 85000.00 | 629200.00 | 629200.00 |
| 2025-01-10 | 2250.00 | 631450.00 | 631450.00 |
| 2025-01-11 | 6300.00 | 637750.00 | 637750.00 |
| 2025-01-13 | 305500.00 | 943250.00 | 943250.00 |
| 2025-01-14 | 165000.00 | 1108250.00 | 1108250.00 |
| 2025-01-15 | 248400.00 | 1356650.00 | 1356650.00 |
| 2025-01-16 | 30000.00 | 1386650.00 | 1386650.00 |
| 2025-01-17 | 140000.00 | 1526650.00 | 1526650.00 |
| 2025-01-19 | 90000.00 | 1616650.00 | 1616650.00 |
| 2025-01-22 | 195000.00 | 1811650.00 | 1811650.00 |
| 2025-01-23 | 130000.00 | 1941650.00 | 1941650.00 |
... showing 15 of 135 rows
Running count of orders and running customer count per year
SELECT OrderID, OrderDate, CustomerID,
COUNT(*) OVER (PARTITION BY YEAR(OrderDate) ORDER BY OrderDate, OrderID ROWS UNBOUNDED PRECEDING) AS OrdersSoFarThisYear
FROM dbo.Orders
ORDER BY OrderDate, OrderID;
Output
| OrderID | OrderDate | CustomerID | OrdersSoFarThisYear |
|---|---|---|---|
| 1 | 2024-01-09 | 1 | 1 |
| 2 | 2024-01-17 | 8 | 2 |
| 3 | 2024-01-26 | 8 | 3 |
| 4 | 2024-01-29 | 13 | 4 |
| 5 | 2024-02-06 | 3 | 5 |
| 6 | 2024-02-14 | 15 | 6 |
| 7 | 2024-02-20 | 4 | 7 |
| 8 | 2024-02-27 | 7 | 8 |
| 9 | 2024-03-06 | 3 | 9 |
| 10 | 2024-03-12 | 1 | 10 |
| 11 | 2024-03-17 | 17 | 11 |
| 12 | 2024-03-25 | 16 | 12 |
| 13 | 2024-04-02 | 1 | 13 |
| 14 | 2024-04-05 | 2 | 14 |
| 15 | 2024-04-14 | 3 | 15 |
... showing 15 of 80 rows
Part B: Moving Averages
3-day moving average of stock prices
SELECT Symbol, TradeDate, ClosePrice,
CAST(AVG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS DECIMAL(10,2)) AS MA3,
CAST(AVG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate
ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) AS DECIMAL(10,2)) AS MA5,
COUNT(*) OVER (PARTITION BY Symbol ORDER BY TradeDate
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS RowsInWindow
FROM dbo.StockPrices
ORDER BY Symbol, TradeDate;
-- First rows have fewer than 3 values. Use RowsInWindow to hide incomplete averages if needed.
Output
| Symbol | TradeDate | ClosePrice | MA3 | MA5 | RowsInWindow |
|---|---|---|---|---|---|
| INFY | 2025-06-02 | 1520.50 | 1520.50 | 1520.50 | 1 |
| INFY | 2025-06-03 | 1534.20 | 1527.35 | 1527.35 | 2 |
| INFY | 2025-06-04 | 1528.00 | 1527.57 | 1527.57 | 3 |
| INFY | 2025-06-05 | 1550.75 | 1537.65 | 1533.36 | 3 |
| INFY | 2025-06-06 | 1561.10 | 1546.62 | 1538.91 | 3 |
| INFY | 2025-06-09 | 1549.90 | 1553.92 | 1544.79 | 3 |
| INFY | 2025-06-10 | 1572.30 | 1561.10 | 1552.41 | 3 |
| INFY | 2025-06-11 | 1580.00 | 1567.40 | 1562.81 | 3 |
| TCS | 2025-06-02 | 3410.00 | 3410.00 | 3410.00 | 1 |
| TCS | 2025-06-03 | 3398.40 | 3404.20 | 3404.20 | 2 |
| TCS | 2025-06-04 | 3425.60 | 3411.33 | 3411.33 | 3 |
| TCS | 2025-06-05 | 3440.10 | 3421.37 | 3418.53 | 3 |
| TCS | 2025-06-06 | 3431.00 | 3432.23 | 3421.02 | 3 |
| TCS | 2025-06-09 | 3460.80 | 3443.97 | 3431.18 | 3 |
| TCS | 2025-06-10 | 3455.25 | 3449.02 | 3442.55 | 3 |
... showing 15 of 24 rows
7-day moving average of daily sales (fill missing days first with a calendar)
WITH Calendar AS
(
SELECT CAST('2025-01-01' AS DATE) AS d
UNION ALL SELECT DATEADD(DAY, 1, d) FROM Calendar WHERE d < '2025-06-30'
),
Daily AS
(
SELECT c.d AS SaleDate, ISNULL(SUM(s.TotalAmount), 0) AS DayRevenue
FROM Calendar c LEFT JOIN dbo.Sales s ON s.SaleDate = c.d
GROUP BY c.d
)
SELECT SaleDate, DayRevenue,
CAST(AVG(DayRevenue) OVER (ORDER BY SaleDate ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS DECIMAL(12,2)) AS MovingAvg7Day,
SUM(DayRevenue) OVER (ORDER BY SaleDate ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS Rolling7DayTotal
FROM Daily
ORDER BY SaleDate
OPTION (MAXRECURSION 200);
Output
| SaleDate | DayRevenue | MovingAvg7Day | Rolling7DayTotal |
|---|---|---|---|
| 2025-01-01 | 0.00 | 0.00 | 0.00 |
| 2025-01-02 | 245400.00 | 122700.00 | 245400.00 |
| 2025-01-03 | 0.00 | 81800.00 | 245400.00 |
| 2025-01-04 | 1800.00 | 61800.00 | 247200.00 |
| 2025-01-05 | 36000.00 | 56640.00 | 283200.00 |
| 2025-01-06 | 261000.00 | 90700.00 | 544200.00 |
| 2025-01-07 | 85000.00 | 89885.71 | 629200.00 |
| 2025-01-08 | 0.00 | 89885.71 | 629200.00 |
| 2025-01-09 | 0.00 | 54828.57 | 383800.00 |
| 2025-01-10 | 2250.00 | 55150.00 | 386050.00 |
| 2025-01-11 | 6300.00 | 55792.86 | 390550.00 |
| 2025-01-12 | 0.00 | 50650.00 | 354550.00 |
| 2025-01-13 | 305500.00 | 57007.14 | 399050.00 |
| 2025-01-14 | 165000.00 | 68435.71 | 479050.00 |
| 2025-01-15 | 248400.00 | 103921.43 | 727450.00 |
... showing 15 of 181 rows
Centered moving average (1 before, current, 1 after)
SELECT Symbol, TradeDate, ClosePrice,
CAST(AVG(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS DECIMAL(10,2)) AS CenteredMA3
FROM dbo.StockPrices
WHERE Symbol = 'INFY';
Output
| Symbol | TradeDate | ClosePrice | CenteredMA3 |
|---|---|---|---|
| INFY | 2025-06-02 | 1520.50 | 1527.35 |
| INFY | 2025-06-03 | 1534.20 | 1527.57 |
| INFY | 2025-06-04 | 1528.00 | 1537.65 |
| INFY | 2025-06-05 | 1550.75 | 1546.62 |
| INFY | 2025-06-06 | 1561.10 | 1553.92 |
| INFY | 2025-06-09 | 1549.90 | 1561.10 |
| INFY | 2025-06-10 | 1572.30 | 1567.40 |
| INFY | 2025-06-11 | 1580.00 | 1576.15 |
Rolling 3-month revenue (monthly level)
WITH Monthly AS
(
SELECT DATEFROMPARTS(YEAR(o.OrderDate), MONTH(o.OrderDate), 1) AS OrderMonth,
SUM(oi.LineTotal) AS Revenue
FROM dbo.Orders o JOIN dbo.OrderItems oi ON oi.OrderID = o.OrderID
GROUP BY DATEFROMPARTS(YEAR(o.OrderDate), MONTH(o.OrderDate), 1)
)
SELECT OrderMonth, Revenue,
SUM(Revenue) OVER (ORDER BY OrderMonth ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS Rolling3MonthRevenue,
AVG(Revenue) OVER (ORDER BY OrderMonth ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS Rolling3MonthAvg
FROM Monthly
ORDER BY OrderMonth;
Output
| OrderMonth | Revenue | Rolling3MonthRevenue | Rolling3MonthAvg |
|---|---|---|---|
| 2024-01-01 | 851410.00 | 851410.00 | 851410.000000 |
| 2024-02-01 | 757610.00 | 1609020.00 | 804510.000000 |
| 2024-03-01 | 885825.00 | 2494845.00 | 831615.000000 |
| 2024-04-01 | 903466.50 | 2546901.50 | 848967.166666 |
| 2024-05-01 | 895150.00 | 2684441.50 | 894813.833333 |
| 2024-06-01 | 953160.00 | 2751776.50 | 917258.833333 |
| 2024-07-01 | 808200.00 | 2656510.00 | 885503.333333 |
| 2024-08-01 | 709800.00 | 2471160.00 | 823720.000000 |
| 2024-09-01 | 508075.00 | 2026075.00 | 675358.333333 |
| 2024-10-01 | 584910.00 | 1802785.00 | 600928.333333 |
| 2024-11-01 | 446850.00 | 1539835.00 | 513278.333333 |
| 2024-12-01 | 388775.00 | 1420535.00 | 473511.666666 |
| 2025-01-01 | 757675.00 | 1593300.00 | 531100.000000 |
| 2025-02-01 | 373550.00 | 1520000.00 | 506666.666666 |
| 2025-03-01 | 655075.00 | 1786300.00 | 595433.333333 |
... showing 15 of 18 rows
Part C: Other Frame Tricks
Rolling MIN / MAX: 5-day high and low
SELECT Symbol, TradeDate, ClosePrice,
MAX(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) AS High5Day,
MIN(ClosePrice) OVER (PARTITION BY Symbol ORDER BY TradeDate ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) AS Low5Day
FROM dbo.StockPrices
ORDER BY Symbol, TradeDate;
Output
| Symbol | TradeDate | ClosePrice | High5Day | Low5Day |
|---|---|---|---|---|
| INFY | 2025-06-02 | 1520.50 | 1520.50 | 1520.50 |
| INFY | 2025-06-03 | 1534.20 | 1534.20 | 1520.50 |
| INFY | 2025-06-04 | 1528.00 | 1534.20 | 1520.50 |
| INFY | 2025-06-05 | 1550.75 | 1550.75 | 1520.50 |
| INFY | 2025-06-06 | 1561.10 | 1561.10 | 1520.50 |
| INFY | 2025-06-09 | 1549.90 | 1561.10 | 1528.00 |
| INFY | 2025-06-10 | 1572.30 | 1572.30 | 1528.00 |
| INFY | 2025-06-11 | 1580.00 | 1580.00 | 1549.90 |
| TCS | 2025-06-02 | 3410.00 | 3410.00 | 3410.00 |
| TCS | 2025-06-03 | 3398.40 | 3410.00 | 3398.40 |
| TCS | 2025-06-04 | 3425.60 | 3425.60 | 3398.40 |
| TCS | 2025-06-05 | 3440.10 | 3440.10 | 3398.40 |
| TCS | 2025-06-06 | 3431.00 | 3440.10 | 3398.40 |
| TCS | 2025-06-09 | 3460.80 | 3460.80 | 3398.40 |
| TCS | 2025-06-10 | 3455.25 | 3460.80 | 3425.60 |
... showing 15 of 24 rows
Remaining total: how much stock value is still "ahead"
SELECT ProductName, UnitPrice * StockQty AS StockValue,
SUM(UnitPrice * StockQty) OVER (ORDER BY UnitPrice * StockQty DESC
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS RemainingValue
FROM dbo.Products;
Output
| ProductName | StockValue | RemainingValue |
|---|---|---|
| Old Model Tablet | 0.00 | 0.00 |
| Ball Pen (Pack of 10) | 48000.00 | 48000.00 |
| Desk Organizer | 67500.00 | 115500.00 |
| A4 Notebook (Pack of 5) | 75000.00 | 190500.00 |
| Laser Printer | 80000.00 | 270500.00 |
| Wireless Mouse | 240000.00 | 510500.00 |
| USB-C Hub | 280000.00 | 790500.00 |
| Mechanical Keyboard | 300000.00 | 1090500.00 |
| Standing Desk | 396000.00 | 1486500.00 |
| Ergonomic Office Chair | 420000.00 | 1906500.00 |
| Basic Office Chair | 425000.00 | 2331500.00 |
| Lenovo ThinkCentre | 440000.00 | 2771500.00 |
| Acer Aspire Desktop | 540000.00 | 3311500.00 |
| 27 inch Monitor | 540000.00 | 3851500.00 |
| Redmi Note 13 | 900000.00 | 4751500.00 |
... showing 15 of 20 rows
Pareto / 80-20 analysis: which products make 80% of revenue?
WITH ProdRev AS
(
SELECT p.ProductName, SUM(oi.LineTotal) AS Revenue
FROM dbo.OrderItems oi JOIN dbo.Products p ON p.ProductID = oi.ProductID
GROUP BY p.ProductName
),
Cume AS
(
SELECT ProductName, Revenue,
SUM(Revenue) OVER (ORDER BY Revenue DESC ROWS UNBOUNDED PRECEDING) * 100.0
/ SUM(Revenue) OVER () AS CumulativePct
FROM ProdRev
)
SELECT ProductName, Revenue, CAST(CumulativePct AS DECIMAL(5,2)) AS CumulativePct,
CASE WHEN CumulativePct <= 80 THEN 'A (top 80%)' ELSE 'B/C' END AS ParetoClass
FROM Cume
ORDER BY Revenue DESC;
Output
| ProductName | Revenue | CumulativePct | ParetoClass |
|---|---|---|---|
| MacBook Air M3 | 3408000.00 | 27.41 | A (top 80%) |
| HP Pavilion 14 | 1938000.00 | 43.00 | A (top 80%) |
| iPhone 15 | 1774500.00 | 57.27 | A (top 80%) |
| Lenovo ThinkCentre | 1438250.00 | 68.84 | A (top 80%) |
| Acer Aspire Desktop | 915750.00 | 76.21 | A (top 80%) |
| Dell Inspiron 15 | 828750.00 | 82.87 | B/C |
| 27 inch Monitor | 510300.00 | 86.98 | B/C |
| Samsung Galaxy A55 | 355000.00 | 89.83 | B/C |
| Redmi Note 13 | 337500.00 | 92.55 | B/C |
| Standing Desk | 325600.00 | 95.16 | B/C |
| Ergonomic Office Chair | 201000.00 | 96.78 | B/C |
| Basic Office Chair | 171700.00 | 98.16 | B/C |
| Wireless Mouse | 84660.00 | 98.84 | B/C |
| Mechanical Keyboard | 69250.00 | 99.40 | B/C |
| USB-C Hub | 47075.00 | 99.78 | B/C |
... showing 15 of 18 rows
Practice Questions
- Show running total of 2025 sales per Region, by date.
- Show a 3-order moving average of order value for each customer.
- Show each account's month-end balance (hint: running total + last row per month).
- Find the first date when cumulative 2025 sales crossed 50 lakh (5,000,000).
- Compute a 7-day rolling count of orders.