Loading content...
Throughout this module, we've built comprehensive knowledge of cursors: their concepts, declaration, operations, and types. Now comes the most important question: when should you actually use them?
The honest answer is: rarely.
This isn't to diminish what you've learned—understanding cursors is essential for any database professional. But that understanding should lead you to recognize that cursors are a specialized tool, not a general-purpose solution.
Many database performance problems stem from cursor overuse. Developers comfortable with procedural programming naturally think in loops: "for each row, do something." But databases are optimized for set operations, not iterations. Using cursors where set-based solutions exist is like using a hand saw when a power saw is available—you'll eventually get the job done, but at tremendous cost.
This page will help you:
By the end of this page, you will know when to use cursors and when to avoid them. You'll understand legitimate use cases (external API calls, complex procedural logic, audit trails), recognize anti-patterns (masked set operations, row-by-row reporting), and master best practices for efficient cursor implementation when they're genuinely needed.
Before examining cursor use cases, we must internalize the fundamental principle: SQL is designed for set-based operations, and databases are optimized for set processing.
When you write a set-based query, the database engine can:
When you use a cursor, you forfeit most of these optimizations. The database processes each row individually, with context switching, lock management, and I/O overhead for every iteration.
| Operation | Set-Based | Cursor-Based | Difference |
|---|---|---|---|
| Update 100,000 rows | 0.5 seconds | 45 seconds | 90× slower |
| Calculate running totals | 0.2 seconds | 30 seconds | 150× slower |
| Delete duplicate rows | 0.8 seconds | 120 seconds | 150× slower |
| Join and aggregate | 0.3 seconds | 60 seconds | 200× slower |
| Insert with transformation | 0.4 seconds | 50 seconds | 125× slower |
The Rule:
If a task CAN be accomplished with a set-based SQL statement, it SHOULD be.
Only when you have requirements that genuinely cannot be expressed as set operations should you reach for cursors.
1234567891011121314151617181920212223242526272829303132333435363738394041
-- ANTI-PATTERN: Cursor to update salariesDECLARE salary_cursor CURSOR FOR SELECT emp_id, salary FROM employees;OPEN salary_cursor;FETCH NEXT FROM salary_cursor INTO @emp_id, @salary;WHILE @@FETCH_STATUS = 0BEGIN UPDATE employees SET salary = @salary * 1.10 WHERE emp_id = @emp_id; FETCH NEXT FROM salary_cursor INTO @emp_id, @salary;ENDCLOSE salary_cursor;DEALLOCATE salary_cursor;-- Time: 45 seconds for 100K rows -- CORRECT: Set-based updateUPDATE employees SET salary = salary * 1.10;-- Time: 0.5 seconds for 100K rows -- ANTI-PATTERN: Cursor to calculate running totalsDECLARE @running_total DECIMAL(15,2) = 0;DECLARE total_cursor CURSOR FOR SELECT order_id, amount FROM orders ORDER BY order_date;-- ... loop to accumulate @running_total ... -- CORRECT: Window function (SQL Server 2012+, Oracle, PostgreSQL)SELECT order_id, amount, SUM(amount) OVER (ORDER BY order_date) AS running_totalFROM orders; -- ANTI-PATTERN: Cursor to delete duplicatesDECLARE @last_email VARCHAR(200) = '';DECLARE dup_cursor CURSOR FOR SELECT customer_id, email FROM customers ORDER BY email, customer_id;-- ... loop checking if email = @last_email ... -- CORRECT: CTE with ROW_NUMBERWITH numbered AS ( SELECT customer_id, email, ROW_NUMBER() OVER (PARTITION BY email ORDER BY customer_id) AS rn FROM customers)DELETE FROM numbered WHERE rn > 1;Every time you consider a cursor, ask: 'Can this be done with a single UPDATE, DELETE, INSERT, or SELECT statement?' If the answer might be yes, spend time exploring set-based solutions before reaching for a cursor. The effort repays itself many times over.
Despite the strong preference for set-based operations, legitimate cursor use cases exist. These are scenarios where row-by-row processing is genuinely unavoidable or where cursors provide significant advantages.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
-- USE CASE 1: External API calls-- Sending emails or calling external services for each row-- Cannot be parallelized or batched due to external system constraints DECLARE @customer_id INT, @email VARCHAR(200), @notification_type VARCHAR(50); DECLARE notification_cursor CURSOR FAST_FORWARD FOR SELECT customer_id, email, notification_type FROM pending_notifications WHERE sent_date IS NULL ORDER BY priority DESC; OPEN notification_cursor;FETCH NEXT FROM notification_cursor INTO @customer_id, @email, @notification_type; WHILE @@FETCH_STATUS = 0BEGIN -- Call external email service (row-by-row is unavoidable) EXEC dbo.SendEmailViaExternalAPI @email, @notification_type; UPDATE pending_notifications SET sent_date = GETDATE() WHERE customer_id = @customer_id AND notification_type = @notification_type; FETCH NEXT FROM notification_cursor INTO @customer_id, @email, @notification_type;END CLOSE notification_cursor;DEALLOCATE notification_cursor; -- USE CASE 2: Complex inter-row dependent logic-- Inventory allocation where each allocation affects subsequent availability-- Cannot be expressed as single UPDATE DECLARE @order_id INT, @product_id INT, @qty_requested INT;DECLARE @qty_available INT; DECLARE allocation_cursor CURSOR FOR SELECT o.order_id, ol.product_id, ol.quantity FROM orders o INNER JOIN order_lines ol ON o.order_id = ol.order_id WHERE o.status = 'pending_allocation' ORDER BY o.priority, o.order_date -- Priority matters! FOR UPDATE; OPEN allocation_cursor;FETCH NEXT FROM allocation_cursor INTO @order_id, @product_id, @qty_requested; WHILE @@FETCH_STATUS = 0BEGIN -- Get CURRENT available quantity (affected by previous allocations) SELECT @qty_available = available_quantity FROM inventory WHERE product_id = @product_id; IF @qty_available >= @qty_requested BEGIN -- Allocate (reduces availability for subsequent orders) UPDATE inventory SET available_quantity = available_quantity - @qty_requested, allocated_quantity = allocated_quantity + @qty_requested WHERE product_id = @product_id; UPDATE order_lines SET allocated = 1 WHERE CURRENT OF allocation_cursor; END ELSE BEGIN -- Partial allocation or wait UPDATE order_lines SET allocation_status = 'pending_stock' WHERE CURRENT OF allocation_cursor; END FETCH NEXT FROM allocation_cursor INTO @order_id, @product_id, @qty_requested;END CLOSE allocation_cursor;DEALLOCATE allocation_cursor; -- USE CASE 3: Batch commit processing-- Process millions of rows without holding huge transaction DECLARE @batch_size INT = 10000;DECLARE @processed INT = 0;DECLARE @transaction_id BIGINT; DECLARE cleanup_cursor CURSOR GLOBAL FOR -- GLOBAL survives commit SELECT transaction_id FROM old_transactions WHERE transaction_date < DATEADD(YEAR, -7, GETDATE()) ORDER BY transaction_id; OPEN cleanup_cursor;FETCH NEXT FROM cleanup_cursor INTO @transaction_id; WHILE @@FETCH_STATUS = 0BEGIN -- Archive the transaction INSERT INTO transaction_archive SELECT * FROM transactions WHERE id = @transaction_id; DELETE FROM transactions WHERE id = @transaction_id; SET @processed = @processed + 1; -- Commit every batch_size rows IF @processed % @batch_size = 0 BEGIN COMMIT; BEGIN TRANSACTION; PRINT 'Processed ' + CAST(@processed AS VARCHAR) + ' rows, committed.'; END FETCH NEXT FROM cleanup_cursor INTO @transaction_id;END COMMIT;CLOSE cleanup_cursor;DEALLOCATE cleanup_cursor;A reliable indicator of legitimate cursor use: Does each row require interaction with something outside the database? API calls, file writes, external system notifications, or hardware interfaces often justify cursors because these external systems can't handle set operations.
Recognizing anti-patterns helps you avoid common mistakes. These are situations where cursors are often used but shouldn't be—set-based alternatives exist and perform dramatically better.
Anti-Pattern: Updating each row in a loop
This is the most common cursor anti-pattern. Developers iterate through rows, calculating a new value and updating each one.
1234567891011121314151617181920212223242526272829
-- ❌ ANTI-PATTERN: Row-by-row updateDECLARE @id INT, @current_value DECIMAL(10,2); DECLARE update_cursor CURSOR FOR SELECT product_id, price FROM products;OPEN update_cursor;FETCH NEXT FROM update_cursor INTO @id, @current_value; WHILE @@FETCH_STATUS = 0BEGIN -- Apply 15% price increase UPDATE products SET price = @current_value * 1.15 WHERE product_id = @id; FETCH NEXT FROM update_cursor INTO @id, @current_value;END CLOSE update_cursor;DEALLOCATE update_cursor; -- ✓ CORRECT: Set-based updateUPDATE products SET price = price * 1.15; -- Even complex conditions can be set-based:UPDATE products SET price = CASE WHEN category = 'Electronics' THEN price * 1.10 WHEN category = 'Clothing' THEN price * 1.20 ELSE price * 1.15ENDWHERE discontinued = 0;The common thread in anti-patterns: thinking procedurally instead of declaratively. 'For each row, do X' translates to cursors. 'Transform the set according to rule X' translates to efficient SQL. Train yourself to think in sets.
Modern SQL features have eliminated many historical reasons for cursors. Before reaching for a cursor, consider these alternatives:
| Former Cursor Use | Modern Alternative | Available In |
|---|---|---|
| Running totals/aggregates | Window functions (SUM OVER, AVG OVER) | SQL Server 2012+, Oracle 8i+, PostgreSQL 8.4+ |
| Ranking and numbering | ROW_NUMBER, RANK, DENSE_RANK, NTILE | All major databases |
| Previous/next row access | LAG, LEAD window functions | SQL Server 2012+, Oracle, PostgreSQL |
| Conditional insert/update | MERGE statement (UPSERT) | SQL Server 2008+, Oracle, PostgreSQL 15+ |
| Complex multi-step logic | Common Table Expressions (CTEs) | All major databases |
| Recursive processing | Recursive CTEs (WITH RECURSIVE) | SQL Server 2005+, Oracle, PostgreSQL |
| String aggregation | STRING_AGG, LISTAGG, FOR XML | SQL Server 2017+, Oracle, PostgreSQL |
| Row-by-row transformation | CROSS APPLY / LATERAL | SQL Server 2005+, PostgreSQL 9.3+ |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
-- ALTERNATIVE 1: Window Functions-- Eliminate cursors for running calculations -- Running totals, moving averages, rankings - all set-basedSELECT transaction_id, account_id, amount, -- Running total per account SUM(amount) OVER (PARTITION BY account_id ORDER BY transaction_date) AS running_balance, -- Previous transaction amount LAG(amount) OVER (PARTITION BY account_id ORDER BY transaction_date) AS prev_amount, -- Rank by amount within account RANK() OVER (PARTITION BY account_id ORDER BY amount DESC) AS amount_rankFROM transactions; -- ALTERNATIVE 2: MERGE Statement-- Eliminate cursors for conditional insert/update MERGE INTO target_table AS targetUSING source_table AS sourceON target.id = source.idWHEN MATCHED THEN UPDATE SET target.value = source.value, target.updated = GETDATE()WHEN NOT MATCHED BY TARGET THEN INSERT (id, value, created) VALUES (source.id, source.value, GETDATE())WHEN NOT MATCHED BY SOURCE THEN DELETE; -- ALTERNATIVE 3: Recursive CTEs-- Eliminate cursors for hierarchical data traversal WITH RECURSIVE org_hierarchy AS ( -- Base case: top-level managers SELECT employee_id, name, manager_id, 1 AS level FROM employees WHERE manager_id IS NULL UNION ALL -- Recursive case: subordinates SELECT e.employee_id, e.name, e.manager_id, h.level + 1 FROM employees e INNER JOIN org_hierarchy h ON e.manager_id = h.employee_id)SELECT * FROM org_hierarchy ORDER BY level, name; -- ALTERNATIVE 4: CROSS APPLY / LATERAL-- Eliminate cursors for row-by-row function application SELECT c.customer_id, c.name, recent.order_id, recent.order_dateFROM customers cCROSS APPLY ( SELECT TOP 3 order_id, order_date FROM orders o WHERE o.customer_id = c.customer_id ORDER BY order_date DESC) AS recent; -- ALTERNATIVE 5: Numbers Table / Tally Table-- Eliminate cursors for generating sequences -- Create or use existing numbers tableWITH numbers AS ( SELECT TOP 1000 ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n FROM sys.objects a CROSS JOIN sys.objects b)SELECT DATEADD(DAY, n-1, '2024-01-01') AS calendar_dateFROM numbersWHERE DATEADD(DAY, n-1, '2024-01-01') <= '2024-12-31';Every hour spent mastering window functions, CTEs, and MERGE statements pays back thousands of hours in avoided cursor debugging and performance tuning. These features exist specifically to replace cursor patterns.
When cursors are genuinely required, following best practices minimizes their impact and ensures reliable operation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
-- BEST PRACTICE TEMPLATE: Production-quality cursor implementation DECLARE @error_count INT = 0;DECLARE @success_count INT = 0;DECLARE @max_rows INT = 100000; -- Safety limit -- Declare variables for ONLY the columns neededDECLARE @order_id INT, @customer_email VARCHAR(200); -- Use FAST_FORWARD for simple iterationDECLARE order_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT order_id, customer_email -- Only needed columns FROM orders WHERE notification_sent = 0 ORDER BY order_date; BEGIN TRY OPEN order_cursor; FETCH NEXT FROM order_cursor INTO @order_id, @customer_email; WHILE @@FETCH_STATUS = 0 AND (@success_count + @error_count) < @max_rows BEGIN BEGIN TRY -- Tight processing block EXEC dbo.SendOrderConfirmation @order_id, @customer_email; UPDATE orders SET notification_sent = 1 WHERE order_id = @order_id; SET @success_count = @success_count + 1; END TRY BEGIN CATCH -- Log error but continue processing INSERT INTO error_log (order_id, error_message, error_time) VALUES (@order_id, ERROR_MESSAGE(), GETDATE()); SET @error_count = @error_count + 1; END CATCH -- Periodic commit for large batches IF (@success_count + @error_count) % 1000 = 0 BEGIN PRINT 'Progress: ' + CAST(@success_count AS VARCHAR) + ' sent, ' + CAST(@error_count AS VARCHAR) + ' failed'; END FETCH NEXT FROM order_cursor INTO @order_id, @customer_email; ENDEND TRYBEGIN CATCH -- Handle fatal errors PRINT 'Fatal error: ' + ERROR_MESSAGE();END CATCH -- GUARANTEED CLEANUPIF CURSOR_STATUS('local', 'order_cursor') >= 0 CLOSE order_cursor;IF CURSOR_STATUS('local', 'order_cursor') >= -1 DEALLOCATE order_cursor; -- Report resultsSELECT @success_count AS rows_sent, @error_count AS rows_failed; -- ANTI-PATTERN: What to avoid-- ❌ Nested cursorsDECLARE outer_cursor CURSOR FOR SELECT dept_id FROM departments;OPEN outer_cursor;FETCH NEXT FROM outer_cursor INTO @dept_id;WHILE @@FETCH_STATUS = 0BEGIN DECLARE inner_cursor CURSOR FOR -- ❌ Cursor inside cursor! SELECT emp_id FROM employees WHERE department_id = @dept_id; -- This is O(n²) - use a JOIN instead!END -- ✓ CORRECT: Replace nested cursors with JOINDECLARE flat_cursor CURSOR FAST_FORWARD FOR SELECT d.dept_id, d.name, e.emp_id, e.salary FROM departments d INNER JOIN employees e ON d.dept_id = e.department_id ORDER BY d.dept_id, e.emp_id;Nested cursors are almost always wrong. If you find yourself declaring a cursor inside a cursor loop, stop. Use a JOIN to flatten the data into a single cursor, or refactor the logic entirely. Nested cursors turn O(n) into O(n²) or worse.
When you must use cursors, monitoring and optimization become critical. Here's how to identify and address cursor performance problems.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
-- SQL Server: Monitor cursor usageSELECT c.name AS cursor_name, c.properties, c.creation_time, c.is_open, c.worker_time, -- CPU time in microseconds c.reads, -- Logical reads c.writes, -- Logical writes c.dormant_duration -- Time since last fetchFROM sys.dm_exec_cursors(@@SPID) c; -- Current session -- All sessions' cursors (requires VIEW SERVER STATE)SELECT s.session_id, s.login_name, c.name, c.properties, c.creation_time, c.is_openFROM sys.dm_exec_sessions sCROSS APPLY sys.dm_exec_cursors(s.session_id) cWHERE c.is_open = 1; -- Oracle: Monitor cursor usageSELECT s.sid, s.serial#, s.username, oc.cursor_type, oc.sql_id, oc.child_address, oc.statusFROM v$open_cursor ocJOIN v$session s ON oc.sid = s.sidWHERE s.username IS NOT NULL; -- Check for cursor-related wait statesSELECT session_id, wait_type, wait_timeFROM sys.dm_exec_requestsWHERE wait_type LIKE '%CURSOR%'; -- Performance baseline: Time individual operationsDECLARE @start_time DATETIME2, @open_time INT, @fetch_time INT, @close_time INT; SET @start_time = SYSDATETIME();OPEN my_cursor;SET @open_time = DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()); SET @start_time = SYSDATETIME();-- Fetch loop hereSET @fetch_time = DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()); SET @start_time = SYSDATETIME();CLOSE my_cursor;SET @close_time = DATEDIFF(MILLISECOND, @start_time, SYSDATETIME()); PRINT 'OPEN: ' + CAST(@open_time AS VARCHAR) + 'ms';PRINT 'FETCH loop: ' + CAST(@fetch_time AS VARCHAR) + 'ms';PRINT 'CLOSE: ' + CAST(@close_time AS VARCHAR) + 'ms';Optimization Techniques:
| Problem | Symptom | Solution |
|---|---|---|
| Slow OPEN | Long delay before first FETCH | Add indexes on WHERE/ORDER BY columns; simplify query |
| Slow FETCHes | Each FETCH takes noticeable time | Use FAST_FORWARD; reduce columns; check for missing indexes |
| Memory pressure | Server memory exhaustion | Switch from STATIC to FAST_FORWARD; add WHERE to limit rows |
| Lock contention | Blocking other sessions | Use READ_ONLY cursor; reduce time between fetches; commit frequently |
| High CPU | CPU bound during fetch loop | Reduce processing per row; batch into larger chunks |
| Excessive I/O | High disk activity | Use STATIC cursor (all I/O at OPEN); ensure adequate memory |
Always measure before optimizing. Time the OPEN, FETCH loop, and CLOSE separately. Identify which phase is problematic. A slow OPEN requires different optimization than slow FETCHes.
Here's a practical framework for deciding whether to use a cursor in real projects:
The 30-Minute Rule:
Before writing a cursor, spend at least 30 minutes exploring set-based alternatives. Search for:
If after 30 minutes you haven't found a set-based solution, proceed with a cursor—but continue looking for alternatives even while the cursor works. You may refactor later.
The External Test:
A strong indicator of legitimate cursor use:
If yes to any, a cursor is likely appropriate. These external systems cannot handle set-based operations.
Senior database developers are recognized by how rarely they use cursors, not how expertly. The mark of expertise is finding set-based solutions to problems that less experienced developers would solve with cursors. Resist the urge to cursor.
This module has provided comprehensive coverage of SQL cursors—from concepts through best practices. Let's consolidate the key wisdom for practical application:
The Bottom Line:
Cursors are a legitimate tool in the database developer's toolkit—but they should be a last resort, not a first instinct. The mark of database expertise is not knowing how to write cursors; it's knowing when to avoid them.
Use your cursor knowledge not to write more cursors, but to:
Congratulations! You've mastered SQL cursors: concepts, declaration, operations, types, and—most importantly—when to use them and when to seek better alternatives. You can now make informed decisions about row-by-row processing, implement cursors when genuinely needed, and recognize opportunities to refactor to more efficient set-based solutions.