Replacing a Running Total Cursor With a Window Function

A running total cursor can often be replaced by one SUM OVER. The window function keeps a balance in order, the same way the loop does, in a single statement.

An egg slicer lowers parallel wires through one egg beside individually cut egg slices

The loop everyone has written once

You need a balance after every transaction. The first thing many of us wrote was a cursor. Read a row, add it to a variable, store the result, fetch the next row. It works, and it keeps working until the table gets big and the report gets slow.

Before I replace anything, I want proof that the new query gives the same answer. So we build both on the same three rows, including one negative amount.

Build the cursor version

Here is the input. Id gives a unique order. The amounts are 10.00, minus 3.00 and 7.00.

DROP TABLE IF EXISTS #LedgerDemo;
DROP TABLE IF EXISTS #CursorDemo;

CREATE TABLE #LedgerDemo (Id int PRIMARY KEY, Amount decimal(12, 2) NOT NULL);
CREATE TABLE #CursorDemo (Id int PRIMARY KEY, Balance decimal(38, 2) NOT NULL);

INSERT #LedgerDemo (Id, Amount) VALUES (1, 10.00), (2, -3.00), (3, 7.00);

Now the cursor. It visits each row in Id order and writes the running balance into a second table.

SET NOCOUNT ON;
DECLARE @id int, @amount decimal(12, 2), @balance decimal(38, 2) = 0;

DECLARE LedgerCursor CURSOR LOCAL FAST_FORWARD FOR
    SELECT Id, Amount FROM #LedgerDemo ORDER BY Id;

OPEN LedgerCursor;
FETCH NEXT FROM LedgerCursor INTO @id, @amount;

WHILE @@FETCH_STATUS = 0
BEGIN
    SET @balance = @balance + @amount;
    INSERT #CursorDemo (Id, Balance) VALUES (@id, @balance);
    FETCH NEXT FROM LedgerCursor INTO @id, @amount;
END;

CLOSE LedgerCursor;
DEALLOCATE LedgerCursor;

Replace it with SUM OVER

The window version is one expression. I put it next to the cursor result so you can compare them row by row.

SELECT l.Id, l.Amount,
       c.Balance AS CursorBalance,
       SUM(l.Amount) OVER (ORDER BY l.Id ROWS UNBOUNDED PRECEDING) AS WindowBalance
FROM #LedgerDemo AS l
JOIN #CursorDemo AS c ON c.Id = l.Id
ORDER BY l.Id;
Cursor and window results agree on running balances 10, 7, and 14
The cursor and window calculation return the same running balances: 10, 7, and 14.

Both balance columns say 10.00, 7.00 and 14.00. The negative amount lowers the balance before the next row is added, in both versions. ROWS UNBOUNDED PRECEDING means “everything from the first row up to this one.” That is exactly what the loop did.

Ties and the default frame

Here is where people get bitten. Real ledgers have several rows on the same date, and a date does not give a unique order. Let me show you two accounts, with two moves on the same day for account 1.

DROP TABLE IF EXISTS #Moves;
CREATE TABLE #Moves (
    MoveId    int PRIMARY KEY,
    AccountId int NOT NULL,
    MoveDate  date NOT NULL,
    Amount    decimal(12, 2) NOT NULL
);

INSERT #Moves (MoveId, AccountId, MoveDate, Amount) VALUES
    (1, 1, '2026-01-01', 100.00), (2, 1, '2026-01-02', -30.00),
    (3, 1, '2026-01-02', -20.00), (4, 1, '2026-01-03',  50.00),
    (5, 2, '2026-01-01', 500.00), (6, 2, '2026-01-02', -100.00);

SELECT MoveId, AccountId, MoveDate, Amount,
       SUM(Amount) OVER (PARTITION BY AccountId ORDER BY MoveDate) AS DefaultFrame,
       SUM(Amount) OVER (PARTITION BY AccountId ORDER BY MoveDate, MoveId
                         ROWS UNBOUNDED PRECEDING) AS RowsFrame
FROM #Moves
ORDER BY AccountId, MoveDate, MoveId;

Look at moves 2 and 3. They share a date. The DefaultFrame column, which has no ROWS clause, shows 50.00 on both rows, so you never see the balance after move 2 alone. The RowsFrame column steps from 100.00 to 70.00 to 50.00, as the loop would. I added MoveId to the ORDER BY as a tie breaker, which makes the order certain.

PARTITION BY AccountId restarts the balance for each account. With a cursor, you would write extra code to reset the variable. Here, account 2 simply starts again at 500.00.

Write the sequence before you sum

Same answer on more rows

Three rows prove little. Let me load 100,000 rows into a second pair of tables and run both methods. I time each one and compare the balances.

DROP TABLE IF EXISTS #Big;
DROP TABLE IF EXISTS #BigCursor;
DROP TABLE IF EXISTS #BigWindow;

CREATE TABLE #Big (Id int PRIMARY KEY, Amount decimal(12, 2) NOT NULL);
CREATE TABLE #BigCursor (Id int PRIMARY KEY, Balance decimal(38, 2) NOT NULL);

INSERT #Big (Id, Amount)
SELECT value, (value % 7) - 2 FROM GENERATE_SERIES(1, 100000);
SET NOCOUNT ON;
DECLARE @id int, @amount decimal(12, 2), @balance decimal(38, 2) = 0;
DECLARE @start datetime2 = SYSDATETIME();

DECLARE BigCursor CURSOR LOCAL FAST_FORWARD FOR SELECT Id, Amount FROM #Big ORDER BY Id;
OPEN BigCursor;
FETCH NEXT FROM BigCursor INTO @id, @amount;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @balance = @balance + @amount;
    INSERT #BigCursor (Id, Balance) VALUES (@id, @balance);
    FETCH NEXT FROM BigCursor INTO @id, @amount;
END;
CLOSE BigCursor;
DEALLOCATE BigCursor;
SELECT DATEDIFF(MILLISECOND, @start, SYSDATETIME()) AS CursorMs;

SET @start = SYSDATETIME();
SELECT Id, SUM(Amount) OVER (ORDER BY Id ROWS UNBOUNDED PRECEDING) AS Balance
INTO #BigWindow
FROM #Big;
SELECT DATEDIFF(MILLISECOND, @start, SYSDATETIME()) AS WindowMs;

Read CursorMs and WindowMs. The cursor took a few seconds on my test server, while the window query took a fraction of one second. Your times will differ, but the gap should be easy to see.

SELECT COUNT(*) AS RowsCompared,
       SUM(CASE WHEN c.Balance = w.Balance THEN 0 ELSE 1 END) AS MismatchedRows
FROM #BigCursor AS c
JOIN #BigWindow AS w ON w.Id = c.Id;

RowsCompared is 100000 and MismatchedRows is 0. Every balance agrees.

When not to swap

Some balances follow rules a sum cannot express. Think of a cap, a reset after a threshold, or carry-forward logic that depends on the previous result. For those, a loop may still be the right tool. Compare empty input, negative amounts, opening balances and several accounts before you retire a cursor. Then clean up.

DROP TABLE IF EXISTS #LedgerDemo, #CursorDemo, #Moves, #Big, #BigCursor, #BigWindow;

Next time you see a loop adding to a variable, ask whether SUM OVER can say it in one line.

A running balance is not a reason to loop, it is a reason to define the order.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Cursor, SQL Function, SQL Order By, Temp Table
Previous Post
Reading a Backup File Before You Restore With RESTORE HEADERONLY
Next Post
MultiSubnetFailover: Why Clients Time Out After an AG Failover

Related Posts

Leave a Reply

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

Fill out this field
Fill out this field
Please enter a valid email address.