Business Professionals
Power BI | Power Pivot | Power Query | DAX
Cloud Flows | RPA | AI Builder | Copilot
60+ Formulas | Data Stories | Advanced Reporting & Modeling
VB Programming | Report Automation |
MS-Office Automation
Techno-Business Professionals
Power BI | Power Query | Advanced DAX | SQL - Query &
Programming
Microsoft Fabric | Power BI | Power Query | Advanced DAX |
SQL - Query & Programming
Power BI | Power Apps | Power Automate | Copilot Studio | Power Pages | Dataverse
Microsoft Power Apps | Microsoft Power Automate
Power BI | Adv. DAX | SQL (Query & Programming) |
VBA | Python | Web Scrapping | API Integration
Power BI | Power Apps | Power Automate |
SQL (Query & Programming)
Power BI | Adv. DAX | Power Apps | Power Automate |
SQL (Query & Programming) | VBA | Python | Web Scrapping | API Integration
Power Apps | Power Automate | SQL | VBA | Python |
Web Scraping | RPA | API Integration
Technology Professionals
Power BI | DAX | SQL | ETL with SSIS | SSAS | VBA | Python
Power BI | SQL | Azure Data Lake | Synapse Analytics |
Data Factory | Databricks | Power Apps | Power Automate |
Azure Analysis Services
Microsoft Fabric | Power BI | SQL | Lakehouse |
Data Factory (Pipelines) | Dataflows Gen2 | KQL | Delta Tables | Power Apps | Power Automate
Power BI | Power Apps | Power Automate | SQL | VBA | Python | API Integration
Power BI | Advanced DAX | Databricks | SQL | Lakehouse Architecture
Business Professionals
Power BI | Power Pivot | Power Query | DAX
Cloud Flows | RPA | AI Builder | Copilot
60+ Formulas | Data Stories | Advanced Reporting & Modeling
VB Programming | Report Automation |
MS-Office Automation
Techno-Business Professionals
Power BI | Power Query | Advanced DAX | SQL - Query &
Programming
Microsoft Fabric | Power BI | Power Query | Advanced DAX |
SQL - Query & Programming
Power BI | Power Apps | Power Automate | Copilot Studio | Power Pages | Dataverse
Microsoft Power Apps | Microsoft Power Automate
Power BI | Adv. DAX | SQL (Query & Programming) |
VBA | Web Scrapping | API Integration
Power BI | Power Apps | Power Automate |
SQL (Query & Programming)
Power BI | Adv. DAX | Power Apps | Power Automate |
SQL (Query & Programming) | VBA | Web Scrapping | API Integration
Power Apps | Power Automate | SQL | VBA |
Web Scraping | RPA | API Integration
Technology Professionals
Power BI | DAX | SQL | ETL with SSIS | SSAS | VBA
Power BI | SQL | Azure Data Lake | Synapse Analytics |
Data Factory | Azure Analysis Services
Microsoft Fabric | Power BI | SQL | Lakehouse |
Data Factory (Pipelines) | Dataflows Gen2 | KQL | Delta Tables
Power BI | Power Apps | Power Automate | SQL | VBA | API Integration
Power BI | Advanced DAX | Databricks | SQL | Lakehouse Architecture
If your MS-SQL database feels slow and CPU-heavy, chances are you have at least a few cursor-based stored procedures hiding in there. This article shows how to spot them, how to rewrite them into set-based queries, and what kind of before/after performance gains you can realistically expect.
We’ll walk through a realistic example, measure both versions, and then generalise a few patterns you can reuse in your own code. If you want to push these skills further into analytics and reporting, the same set-based thinking carries over directly into strong SQL foundations for Power BI.
Cursors are tempting because they look procedural and familiar:
The problem is that SQL Server is built to work with sets of rows, not one row at a time. Cursors:
Set-based queries, on the other hand:
You won’t always get a 10x improvement, but when you remove a cursor from a hot path (nightly jobs, ETL, financial calculations), the difference is often very noticeable.
Imagine a simple sales schema:
CREATE TABLE dbo.SalesOrder
(
SalesOrderID INT IDENTITY(1,1) PRIMARY KEY,
CustomerID INT NOT NULL,
OrderDate DATE NOT NULL,
Amount DECIMAL(18,2) NOT NULL,
Status VARCHAR(20) NOT NULL
);
CREATE TABLE dbo.CustomerBalance
(
CustomerID INT PRIMARY KEY,
Balance DECIMAL(18,2) NOT NULL,
LastUpdated DATETIME2(0) NOT NULL
);
A legacy stored procedure updates customer balances for all orders in a date range, row by row:
CREATE OR ALTER PROCEDURE dbo.UpdateCustomerBalances_Cursor
@FromDate DATE,
@ToDate DATE
AS
BEGIN
SET NOCOUNT ON;
DECLARE
@SalesOrderID INT,
@CustomerID INT,
@Amount DECIMAL(18,2);
DECLARE order_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT SalesOrderID, CustomerID, Amount
FROM dbo.SalesOrder
WHERE OrderDate >= @FromDate
AND OrderDate < DATEADD(DAY, 1, @ToDate)
AND Status = 'Closed';
OPEN order_cursor;
FETCH NEXT FROM order_cursor
INTO @SalesOrderID, @CustomerID, @Amount;
WHILE @@FETCH_STATUS = 0
BEGIN
-- Upsert-like logic per row
IF EXISTS (SELECT 1 FROM dbo.CustomerBalance WHERE CustomerID = @CustomerID)
BEGIN
UPDATE dbo.CustomerBalance
SET Balance = Balance + @Amount,
LastUpdated = SYSDATETIME()
WHERE CustomerID = @CustomerID;
END
ELSE
BEGIN
INSERT INTO dbo.CustomerBalance (CustomerID, Balance, LastUpdated)
VALUES (@CustomerID, @Amount, SYSDATETIME());
END
FETCH NEXT FROM order_cursor
INTO @SalesOrderID, @CustomerID, @Amount;
END
CLOSE order_cursor;
DEALLOCATE order_cursor;
END;
This is very common:
SELECT, one UPDATE or INSERT per order.Let’s rewrite it.
Look at what the cursor actually does:
Amount to that customer’s balance.We don’t care about individual orders in the balance table, only the sum per customer.
So we can pre-aggregate:
;WITH DailyCustomerTotals AS
(
SELECT
so.CustomerID,
SUM(so.Amount) AS TotalAmount
FROM dbo.SalesOrder AS so
WHERE so.OrderDate >= @FromDate
AND so.OrderDate < DATEADD(DAY, 1, @ToDate)
AND so.Status = 'Closed'
GROUP BY so.CustomerID
)
This CTE now represents the final change per customer. No loops needed.
We now want to:
CustomerBalance.MERGEMERGE expresses this in one statement:
CREATE OR ALTER PROCEDURE dbo.UpdateCustomerBalances_SetBased
@FromDate DATE,
@ToDate DATE
AS
BEGIN
SET NOCOUNT ON;
;WITH DailyCustomerTotals AS
(
SELECT
so.CustomerID,
SUM(so.Amount) AS TotalAmount
FROM dbo.SalesOrder AS so
WHERE so.OrderDate >= @FromDate
AND so.OrderDate < DATEADD(DAY, 1, @ToDate)
AND so.Status = 'Closed'
GROUP BY so.CustomerID
)
MERGE dbo.CustomerBalance AS tgt
USING DailyCustomerTotals AS src
ON tgt.CustomerID = src.CustomerID
WHEN MATCHED THEN
UPDATE SET
tgt.Balance = tgt.Balance + src.TotalAmount,
tgt.LastUpdated = SYSDATETIME()
WHEN NOT MATCHED BY TARGET THEN
INSERT (CustomerID, Balance, LastUpdated)
VALUES (src.CustomerID, src.TotalAmount, SYSDATETIME());
END;
This removes the cursor entirely and lets SQL Server handle:
CustomerBalance and the aggregated totals.UPDATE and INSERTIf you prefer to avoid MERGE (for readability or to sidestep edge cases), you can split it:
CREATE OR ALTER PROCEDURE dbo.UpdateCustomerBalances_SetBased
@FromDate DATE,
@ToDate DATE
AS
BEGIN
SET NOCOUNT ON;
;WITH DailyCustomerTotals AS
(
SELECT
so.CustomerID,
SUM(so.Amount) AS TotalAmount
FROM dbo.SalesOrder AS so
WHERE so.OrderDate >= @FromDate
AND so.OrderDate < DATEADD(DAY, 1, @ToDate)
AND so.Status = 'Closed'
GROUP BY so.CustomerID
)
-- Update existing customers
UPDATE cb
SET cb.Balance = cb.Balance + dct.TotalAmount,
cb.LastUpdated = SYSDATETIME()
FROM dbo.CustomerBalance AS cb
JOIN DailyCustomerTotals AS dct
ON cb.CustomerID = dct.CustomerID;
-- Insert new customers
INSERT INTO dbo.CustomerBalance (CustomerID, Balance, LastUpdated)
SELECT dct.CustomerID, dct.TotalAmount, SYSDATETIME()
FROM DailyCustomerTotals AS dct
LEFT JOIN dbo.CustomerBalance AS cb
ON cb.CustomerID = dct.CustomerID
WHERE cb.CustomerID IS NULL;
END;
Both approaches are set-based and usually far quicker than the cursor.
You don’t need fancy tools to see the difference. Use what SQL Server already gives you.
In SSMS:
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
EXEC dbo.UpdateCustomerBalances_Cursor
@FromDate = '2024-01-01',
@ToDate = '2024-01-31';
EXEC dbo.UpdateCustomerBalances_SetBased
@FromDate = '2024-01-01',
@ToDate = '2024-01-31';
Compare:
The set-based version should:
For meaningful comparisons:
SalesOrder with a realistic volume, e.g.:
You’ll typically see the cursor version grow nearly linearly with row count, while the set-based version scales more smoothly.
Look at the actual execution plans for both procedures:
You want to see:
Most cursor-based procedures fall into a few patterns. Here’s how to think about each.
Pattern:
Set-based replacement:
GROUP BY with SUM, COUNT, MIN, MAX, etc.SUM() OVER (PARTITION BY ...)) when you need per-row context.Example window function:
SELECT
CustomerID,
OrderDate,
Amount,
SUM(Amount) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS RunningBalance
FROM dbo.SalesOrder;
Pattern:
Set-based replacement:
CASE expressions inside a single UPDATE.UPDATE so
SET Status = CASE
WHEN PaymentReceivedDate IS NOT NULL THEN 'Paid'
WHEN DueDate < CAST(GETDATE() AS DATE) THEN 'Overdue'
ELSE Status
END
FROM dbo.SalesOrder AS so
WHERE Status IN ('Open', 'Pending');
Pattern:
Set-based replacement:
Running stock levels example:
;WITH OrderedMovements AS
(
SELECT
ProductID,
MovementDate,
QuantityChange,
ROW_NUMBER() OVER (
PARTITION BY ProductID
ORDER BY MovementDate, MovementID
) AS rn
FROM dbo.StockMovement
),
RecursiveStock AS
(
SELECT
ProductID,
MovementDate,
QuantityChange,
rn,
CAST(QuantityChange AS INT) AS RunningStock
FROM OrderedMovements
WHERE rn = 1
UNION ALL
SELECT
om.ProductID,
om.MovementDate,
om.QuantityChange,
om.rn,
rs.RunningStock + om.QuantityChange
FROM OrderedMovements AS om
JOIN RecursiveStock AS rs
ON om.ProductID = rs.ProductID
AND om.rn = rs.rn + 1
)
SELECT *
FROM RecursiveStock
OPTION (MAXRECURSION 0);
This still isn’t as fast as pure aggregation, but it’s usually better than a cursor and keeps the logic declarative.
Cursors aren’t forbidden; they’re just expensive. They can still be appropriate when:
Even then:
FAST_FORWARD or READ_ONLY cursors when possible.Don’t aim to remove every cursor tomorrow. Instead:
GROUP BY and window functions.MERGE or split UPDATE/INSERT instead of per-row upserts.SET STATISTICS IO/TIME ON and keep the numbers.Once you’ve seen the improvement on a real procedure, it becomes much easier to justify and prioritise similar refactors across your codebase.
MS-SQL
New
Next Batches Now Live
Power BI
SQL
Power Apps
Power Automate
Microsoft Fabrics
Azure Data Engineering