Loading learning content...
Not all cursors are created equal. The type of cursor you choose fundamentally affects:
Choosing the wrong cursor type leads to problems: static cursors on massive result sets exhaust memory; dynamic cursors on complex queries kill performance; forward-only cursors fail when you need random access.
This page provides deep understanding of cursor types across database platforms. While terminology varies (SQL Server uses STATIC/KEYSET/DYNAMIC; standard SQL uses INSENSITIVE/SENSITIVE), the underlying concepts apply universally.
By the end of this page, you will understand all major cursor types: Static (Insensitive), Keyset-driven, Dynamic (Sensitive), and Fast Forward-Only. You'll know their internal implementation, behavior characteristics, performance profiles, and when to use each. You'll be able to select the optimal cursor type for any scenario.
Cursor types are classified along two primary dimensions:
1. Sensitivity: Does the cursor see changes made to underlying data after it opens?
2. Scrollability: What navigation directions are supported?
These dimensions combine to create practical cursor types:
| Type | Sensitivity | Scrollability | Memory | Sees INSERTs | Sees UPDATEs | Sees DELETEs |
|---|---|---|---|---|---|---|
| STATIC | Insensitive | Scrollable | High | No | No | No |
| KEYSET | Partial | Scrollable | Moderate | No | Yes | Detected |
| DYNAMIC | Sensitive | Scrollable* | Low | Yes | Yes | Yes |
| FAST_FORWARD | Asensitive | Forward-only | Minimal | Varies | Varies | Varies |
| FORWARD_ONLY | Typically Dynamic | Forward-only | Low | Yes | Yes | Yes |
SQL Server Terminology vs. Standard SQL:
| SQL Server | Standard SQL | Description |
|---|---|---|
| STATIC | INSENSITIVE | Snapshot isolation |
| DYNAMIC | SENSITIVE | Real-time view |
| KEYSET | (no equivalent) | Key-driven hybrid |
| FAST_FORWARD | (optimization) | Optimized forward-only read-only |
When you don't specify a type, the database chooses based on query characteristics and internal heuristics. This default (ASENSITIVE in standard SQL) means behavior may vary between statements and shouldn't be relied upon for critical code. Always explicitly specify your cursor type.
A static cursor creates a complete snapshot of the result set at OPEN time. This snapshot is stored in a temporary work area (tempdb in SQL Server, work tables in Oracle). All subsequent operations work with this copy, completely isolated from changes to the base tables.
Declaration:
-- SQL Server
DECLARE my_cursor CURSOR STATIC FOR
SELECT * FROM orders WHERE status = 'pending';
-- Standard SQL
DECLARE my_cursor INSENSITIVE CURSOR FOR
SELECT * FROM orders WHERE status = 'pending';
Static Cursor Characteristics:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
-- Static cursor for consistent reportingDECLARE @product_id INT, @product_name VARCHAR(100), @stock INT;DECLARE @total_products INT = 0;DECLARE @low_stock_count INT = 0; DECLARE inventory_report CURSOR STATIC FOR SELECT product_id, product_name, stock_level FROM inventory WHERE warehouse_id = 1 ORDER BY product_name; OPEN inventory_report; -- Even if another user modifies inventory during this loop,-- we see a consistent snapshotFETCH NEXT FROM inventory_report INTO @product_id, @product_name, @stock; WHILE @@FETCH_STATUS = 0BEGIN SET @total_products = @total_products + 1; IF @stock < 10 SET @low_stock_count = @low_stock_count + 1; -- All logic based on consistent data PRINT @product_name + ': ' + CAST(@stock AS VARCHAR) + ' units'; FETCH NEXT FROM inventory_report INTO @product_id, @product_name, @stock;END -- Report totals are consistent with detail rowsPRINT 'Total Products: ' + CAST(@total_products AS VARCHAR);PRINT 'Low Stock Items: ' + CAST(@low_stock_count AS VARCHAR); CLOSE inventory_report;DEALLOCATE inventory_report; -- Demonstration of scrollabilityDECLARE scroll_demo CURSOR STATIC SCROLL FOR SELECT employee_id, hire_date FROM employees ORDER BY hire_date; OPEN scroll_demo; -- Jump to 10th employeeFETCH ABSOLUTE 10 FROM scroll_demo INTO @emp_id, @date;PRINT '10th hired: Employee ' + CAST(@emp_id AS VARCHAR); -- Jump to last employeeFETCH LAST FROM scroll_demo INTO @emp_id, @date;PRINT 'Most recent: Employee ' + CAST(@emp_id AS VARCHAR); -- Go back 5 positionsFETCH RELATIVE -5 FROM scroll_demo INTO @emp_id, @date;PRINT '5 from end: Employee ' + CAST(@emp_id AS VARCHAR); CLOSE scroll_demo;DEALLOCATE scroll_demo;A static cursor on a query returning 1 million rows copies all 1 million rows to temporary storage at OPEN time. For wide rows (many columns, large strings), this can consume gigabytes of temp space. Monitor tempdb usage and set appropriate limits.
A dynamic cursor provides a real-time view of the underlying data. It doesn't materialize a copy—instead, each FETCH operation directly accesses the base tables. This means all concurrent changes (INSERTs, UPDATEs, DELETEs) are immediately visible.
Declaration:
-- SQL Server
DECLARE my_cursor CURSOR DYNAMIC FOR
SELECT * FROM orders WHERE status = 'pending';
-- Standard SQL
DECLARE my_cursor SENSITIVE CURSOR FOR
SELECT * FROM orders WHERE status = 'pending';
Dynamic Cursor Characteristics:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
-- Dynamic cursor for real-time monitoring-- Scenario: Process pending orders, seeing new ones as they arrive DECLARE @order_id INT, @customer_id INT, @total DECIMAL(10,2);DECLARE @processed_count INT = 0;DECLARE @max_to_process INT = 1000; -- Safety limit DECLARE pending_orders CURSOR DYNAMIC FOR SELECT order_id, customer_id, total_amount FROM orders WHERE status = 'pending' ORDER BY order_date; OPEN pending_orders; FETCH NEXT FROM pending_orders INTO @order_id, @customer_id, @total; WHILE @@FETCH_STATUS = 0 AND @processed_count < @max_to_processBEGIN -- Process this order BEGIN TRY EXEC dbo.ProcessSingleOrder @order_id; -- Mark as processed (this UPDATE is visible to cursor!) UPDATE orders SET status = 'processed' WHERE order_id = @order_id; SET @processed_count = @processed_count + 1; END TRY BEGIN CATCH -- Handle error, order remains pending PRINT 'Failed to process order ' + CAST(@order_id AS VARCHAR); END CATCH -- IMPORTANT: New pending orders added by other sessions -- will appear in subsequent FETCHes FETCH NEXT FROM pending_orders INTO @order_id, @customer_id, @total;END PRINT 'Processed ' + CAST(@processed_count AS VARCHAR) + ' orders'; CLOSE pending_orders;DEALLOCATE pending_orders; -- Example: Real-time inventory checkDECLARE @product_id INT, @stock INT, @reserved INT; DECLARE live_inventory CURSOR DYNAMIC FOR SELECT product_id, stock_level, reserved_quantity FROM inventory WHERE stock_level - reserved_quantity < reorder_level FOR UPDATE OF stock_level; OPEN live_inventory; FETCH NEXT FROM live_inventory INTO @product_id, @stock, @reserved; WHILE @@FETCH_STATUS = 0BEGIN -- Current values reflect real-time state PRINT 'Product ' + CAST(@product_id AS VARCHAR) + ' needs reorder: ' + CAST(@stock - @reserved AS VARCHAR) + ' available'; -- Positioned update with current data UPDATE inventory SET stock_level = stock_level + 100, last_reorder_date = GETDATE() WHERE CURRENT OF live_inventory; FETCH NEXT FROM live_inventory INTO @product_id, @stock, @reserved;END CLOSE live_inventory;DEALLOCATE live_inventory;With dynamic cursors, your iteration count is unpredictable. Always include safeguards: maximum iteration limits, timeout checks, or detection of unexpected growth. A dynamic cursor on a table with high insert rate could iterate indefinitely.
A keyset cursor is a hybrid between static and dynamic. At OPEN time, it captures the unique keys (primary key or unique identifier) of all matching rows—but not the data values. When you FETCH, it uses the stored keys to retrieve current column values from the base tables.
Declaration:
-- SQL Server
DECLARE my_cursor CURSOR KEYSET FOR
SELECT * FROM orders WHERE status = 'pending';
Note: Keyset cursors are primarily a SQL Server concept. Other databases achieve similar behavior through different mechanisms.
Keyset Cursor Characteristics:
| Aspect | Behavior | Notes |
|---|---|---|
| Membership | Fixed at OPEN | Keys captured once; new matching rows not visible |
| Values | Current at FETCH | Column values fetched fresh from base tables |
| INSERT by others | Not visible | Row key not in keyset |
| UPDATE by others | Visible | Same key, fetch returns new values |
| DELETE by others | Detected | FETCH returns @@FETCH_STATUS = -2 |
| Memory | Moderate | Keys only, not full row copies |
| Scrollability | Full | Supports all FETCH directions |
| Positioned updates | Supported | WHERE CURRENT OF works |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
-- Keyset cursor example-- Scenario: Review and adjust prices for specific products DECLARE @product_id INT, @name VARCHAR(100), @price DECIMAL(10,2);DECLARE @original_price DECIMAL(10,2); DECLARE price_review CURSOR KEYSET FOR SELECT product_id, product_name, unit_price FROM products WHERE category_id = 5 -- Electronics ORDER BY product_name FOR UPDATE OF unit_price; OPEN price_review; FETCH NEXT FROM price_review INTO @product_id, @name, @price; WHILE @@FETCH_STATUS = 0BEGIN SET @original_price = @price; -- If another user updated the price, we see it now -- (keyset fetches current values) SELECT @price = unit_price FROM products WHERE product_id = @product_id; -- Detect concurrent price changes IF @price <> @original_price PRINT 'Note: Price was concurrently modified for ' + @name; -- Apply adjustment through the cursor IF @price < 50 BEGIN UPDATE products SET unit_price = unit_price * 1.10 -- 10% increase for low-priced items WHERE CURRENT OF price_review; END FETCH NEXT FROM price_review INTO @product_id, @name, @price; -- Handle deleted rows (check @@FETCH_STATUS) IF @@FETCH_STATUS = -2 BEGIN PRINT 'Row was deleted by another user, skipping...'; FETCH NEXT FROM price_review INTO @product_id, @name, @price; ENDEND CLOSE price_review;DEALLOCATE price_review; -- Understanding @@FETCH_STATUS with keyset cursors:-- 0 = Successfully fetched a row-- -1 = Past end of cursor or error-- -2 = Row is missing (was deleted and key no longer exists) DECLARE keyset_demo CURSOR KEYSET FOR SELECT order_id, status FROM orders; OPEN keyset_demo; FETCH NEXT FROM keyset_demo INTO @order_id, @status; WHILE @@FETCH_STATUS <> -1 -- While not at endBEGIN IF @@FETCH_STATUS = 0 BEGIN -- Normal row - process it PRINT 'Processing Order: ' + CAST(@order_id AS VARCHAR); END ELSE IF @@FETCH_STATUS = -2 BEGIN -- Row was deleted - log it PRINT 'Order was deleted, cannot process'; END FETCH NEXT FROM keyset_demo INTO @order_id, @status;END CLOSE keyset_demo;DEALLOCATE keyset_demo;Keyset cursors offer an excellent balance: predictable row count (like static), current values (like dynamic), and full scrollability. They're ideal when you need to iterate a fixed set of records but want to see value updates made by concurrent transactions.
Keyset Cursor Requirements:
Keyset cursors require a unique key to be identifiable for each row. This means:
If these requirements aren't met, SQL Server may silently convert your KEYSET cursor to a STATIC cursor.
Fast forward-only cursors are the most optimized cursor type for sequential, read-only processing. They combine forward-only navigation with read-only access to enable maximum performance optimizations.
Declaration:
-- SQL Server: FAST_FORWARD (combined optimization)
DECLARE my_cursor CURSOR FAST_FORWARD FOR
SELECT * FROM orders WHERE status = 'pending';
-- Equivalent:
DECLARE my_cursor CURSOR FORWARD_ONLY READ_ONLY FOR
SELECT * FROM orders WHERE status = 'pending';
| Optimization | Description | Benefit |
|---|---|---|
| No Materialization | Results streamed directly | Minimal memory |
| No Backward State | No need to track previous rows | Faster FETCH |
| No Lock Overhead | Read-only, no update locks | Better concurrency |
| Minimal Context | Simpler cursor state management | Lower overhead |
| Streaming Possible | Can start returning rows immediately | Faster first row |
| No Key Storage | Unlike keyset, no key set maintained | Memory efficient |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
-- Fast forward-only: Optimal for simple iteration-- This is the MOST EFFICIENT cursor type for read-only sequential processing DECLARE @customer_id INT, @name VARCHAR(100), @email VARCHAR(200);DECLARE @count INT = 0; DECLARE customer_cursor CURSOR FAST_FORWARD FOR SELECT customer_id, customer_name, email FROM customers WHERE active = 1 ORDER BY customer_name; OPEN customer_cursor; FETCH NEXT FROM customer_cursor INTO @customer_id, @name, @email; WHILE @@FETCH_STATUS = 0BEGIN -- Simple processing IF @email IS NOT NULL EXEC dbo.SendNewsletter @customer_id, @email; SET @count = @count + 1; FETCH NEXT FROM customer_cursor INTO @customer_id, @name, @email;END PRINT 'Processed ' + CAST(@count AS VARCHAR) + ' customers'; CLOSE customer_cursor;DEALLOCATE customer_cursor; -- Performance comparison demonstration-- Setup: 100,000 row table -- FAST_FORWARD: ~2 secondsDECLARE fast_cursor CURSOR FAST_FORWARD FOR SELECT id, data FROM large_table; -- DYNAMIC: ~5 seconds (per-fetch overhead)DECLARE dynamic_cursor CURSOR DYNAMIC FOR SELECT id, data FROM large_table; -- STATIC: ~10 seconds (long OPEN, then fast FETCHes)DECLARE static_cursor CURSOR STATIC FOR SELECT id, data FROM large_table; -- But remember: SET-BASED is still ~0.1 seconds!SELECT id, data FROM large_table;FORWARD_ONLY vs FAST_FORWARD:
SQL Server distinction:
-- These are NOT equivalent:
DECLARE cur1 CURSOR FORWARD_ONLY FOR SELECT * FROM t; -- May be dynamic or static
DECLARE cur2 CURSOR FAST_FORWARD FOR SELECT * FROM t; -- Maximum optimization
When you need a cursor and don't require scrolling or updates, always use FAST_FORWARD. It should be your default cursor type. Only add capabilities (SCROLL, FOR UPDATE) when specifically required.
Choosing the right cursor type requires understanding your requirements across multiple dimensions. Use this decision framework:
| Requirement | Best Choice | Alternative | Avoid |
|---|---|---|---|
| Simple sequential iteration | FAST_FORWARD | FORWARD_ONLY | SCROLL cursors |
| Positioned UPDATE/DELETE | KEYSET or DYNAMIC | STATIC, FAST_FORWARD | |
| Bidirectional navigation | KEYSET or STATIC | SCROLL + type | FORWARD_ONLY |
| Report with consistent totals | STATIC | DYNAMIC | |
| Real-time monitoring | DYNAMIC | STATIC (stale) | |
| Large result set, read-only | FAST_FORWARD | DYNAMIC | STATIC (memory) |
| See updates, ignore inserts | KEYSET | DYNAMIC, STATIC | |
| Minimum resource usage | FAST_FORWARD | DYNAMIC | STATIC |
1234567891011121314151617181920212223242526272829
-- RECOMMENDED: Start with FAST_FORWARD, add complexity only when needed -- Scenario 1: Export data to file (read-only, sequential)-- BEST: FAST_FORWARDDECLARE export_cursor CURSOR FAST_FORWARD FOR SELECT * FROM transactions WHERE export_date IS NULL; -- Scenario 2: Apply discounts to specific orders-- BEST: KEYSET (update capability, stable membership)DECLARE discount_cursor CURSOR KEYSET FOR SELECT order_id, total FROM orders WHERE eligible_for_discount = 1 FOR UPDATE OF total; -- Scenario 3: Generate financial report (consistency critical)-- BEST: STATIC (snapshot isolation)DECLARE report_cursor CURSOR STATIC FOR SELECT account_id, balance FROM accounts WHERE account_type = 'savings'; -- Scenario 4: Monitor and process queue items (see new items)-- BEST: DYNAMIC (real-time visibility)DECLARE queue_cursor CURSOR DYNAMIC FOR SELECT queue_id, payload FROM job_queue WHERE processed = 0 FOR UPDATE; -- Scenario 5: Navigate product catalog (jump to positions)-- BEST: KEYSET SCROLL (scrollable, updatable)DECLARE catalog_cursor CURSOR KEYSET SCROLL FOR SELECT product_id, name, price FROM products WHERE active = 1 ORDER BY category, name;Before choosing a cursor type, always ask whether you need a cursor at all. Set-based operations outperform even the fastest cursor type. Use cursors only when row-by-row logic is genuinely required.
Understanding the performance profile of each cursor type helps you make informed decisions. Here's a detailed breakdown:
| Metric | FAST_FORWARD | DYNAMIC | KEYSET | STATIC |
|---|---|---|---|---|
| OPEN Time | Fast (no materialization) | Fast (no materialization) | Moderate (key extraction) | Slow (full copy) |
| FETCH Time | Fastest | Slower (table access) | Moderate (lookup by key) | Fastest (from copy) |
| Memory Usage | Minimal | Minimal | Keys only (moderate) | Full result set (high) |
| Temp Space | None | None | Key storage | Full result set |
| First Row Latency | Best | Good | Moderate | Worst (copy first) |
| Concurrency Impact | Minimal | Higher (per-fetch locks) | Moderate | Minimal (snapshot) |
| Scalability (large results) | Excellent | Good | Moderate | Poor (memory bound) |
Benchmark Example:
Processing 100,000 rows from a medium-complexity query:
| Cursor Type | OPEN | 100K FETCHes | CLOSE | Total |
|---|---|---|---|---|
| FAST_FORWARD | 0.1s | 8s | 0.01s | ~8s |
| DYNAMIC | 0.1s | 15s | 0.01s | ~15s |
| KEYSET | 2s | 10s | 0.1s | ~12s |
| STATIC | 5s | 6s | 0.1s | ~11s |
| SET-BASED | ~0.5s |
Note: These are illustrative figures; actual performance varies significantly based on hardware, query complexity, and data characteristics.
Notice that set-based processing is an order of magnitude faster than even the best cursor. The 16x-30x performance gap illustrates why cursors should be a last resort, not a first approach.
Resource Consumption Patterns:
STATIC:
DYNAMIC:
KEYSET:
FAST_FORWARD:
Understanding cursor types enables you to make informed decisions balancing functionality, performance, and resource consumption. Let's consolidate the key concepts:
What's Next:
With comprehensive knowledge of cursor types, the final page addresses the most important question: when should you actually use cursors? We'll examine legitimate use cases, alternatives to cursors, and best practices for cursor-based development in production systems.
You now understand all major cursor types: their internal mechanics, behavior characteristics, performance profiles, and selection criteria. You can choose the appropriate cursor type for any scenario balancing functionality against resource consumption. Next, we'll examine when to use cursors—and when to choose better alternatives.