Oracle PL/SQL Cursor T-SQL · Cursor

T-SQL Cursor

Master T-SQL cursors — process result sets one row at a time. Covers the full DECLARE / OPEN / FETCH / CLOSE / DEALLOCATE lifecycle with inline diagram, all five cursor types (STATIC, DYNAMIC, KEYSET, FAST_FORWARD, FORWARD_ONLY), LOCAL vs GLOBAL scope, cursor functions (@@FETCH_STATUS, @@CURSOR_ROWS, CURSOR_STATUS) with corrected return values, set-based alternatives, and best practices.

A cursor in T-SQL is a database object that lets you process the rows of a result set one at a time. Where a normal SQL query operates on the entire set at once, a cursor opens a "cursor" (think: a moving pointer) over the result and fetches rows one by one into local variables — letting you apply procedural logic per row.

Cursors come up when you genuinely need row-by-row work: complex per-row calculations that don't fit in a single SQL expression, conditional sequences of operations per row, or producing output where each row's processing depends on accumulated state from previous rows.

⚠️ Cursors are a last resort in T-SQL. The SQL Server engine is heavily optimized for set-based operations — a single UPDATE or MERGE over a million rows is dramatically faster than a cursor that walks them one at a time. As a rule: before reaching for a cursor, ask whether the same job can be expressed as a single set-based statement. Most of the time, it can.

Every T-SQL cursor goes through five stages in strict order:

DECLARE define query OPEN execute query FETCH read next row process it while @@FETCH_STATUS = 0 ↺ loop CLOSE release rows DEALLOCATE free cursor 1 2 3 4 5 Skip any step, you'll leak resources or hit an error.
StepStatementWhat it does
1. DeclareDECLARE name CURSOR FOR ...Defines the cursor and the SELECT it will execute
2. OpenOPEN nameRuns the SELECT; populates the cursor
3. Fetch + loopFETCH NEXT FROM name INTO @varsReads one row into variables; repeat in a WHILE loop
4. CloseCLOSE nameReleases the rows but keeps the cursor's definition
5. DeallocateDEALLOCATE nameRemoves the cursor entirely; frees its memory
📌 CLOSE and DEALLOCATE are both required. CLOSE alone leaves the cursor's definition in memory — you can still OPEN it again. DEALLOCATE is what truly frees it. Forgetting DEALLOCATE in a long-running session leaks server memory.

1. Declare the cursor

DECLARE cursor_name CURSOR [ cursor_type ] FOR select_statement;

cursor_type is optional — if omitted, you get the server's default. The common types (STATIC, DYNAMIC, KEYSET, FAST_FORWARD, FORWARD_ONLY) are covered later in this page.

2. Open the cursor

OPEN cursor_name;

This is the moment the SELECT actually runs.

3. Fetch a row

FETCH NEXT FROM cursor_name INTO @var1, @var2, ... ;

The variable list must match the column list in the SELECT — same number, same data types.

4. Process each row in a WHILE loop

WHILE @@FETCH_STATUS = 0 BEGIN -- work with @var1, @var2, ... FETCH NEXT FROM cursor_name INTO @var1, @var2, ... ; END

5. Close and deallocate

CLOSE cursor_name; DEALLOCATE cursor_name;
Example 1 — Walk an Employees Table

The classic example — walk a department's worth of employees and print each row.

SQL — Setup
CREATE TABLE Employees (
    EmployeeID  INT PRIMARY KEY,
    FirstName   NVARCHAR(50),
    LastName    NVARCHAR(50),
    Department  NVARCHAR(50),
    Salary      DECIMAL(10, 2)
);

INSERT INTO Employees VALUES
    (101, 'John',     'Doe',      'Sales',     50000.00),
    (102, 'Jane',     'Smith',    'Marketing', 60000.00),
    (103, 'Sam',      'Brown',    'Sales',     55000.00),
    (104, 'Sue',      'Green',    'HR',        65000.00),
    (105, 'Bob',      'White',    'Sales',     52000.00),
    (106, 'Chris',    'Brown',    'Marketing', 85000.00),
    (107, 'Patricia', 'Taylor',   'Sales',     65000.00),
    (108, 'Linda',    'Anderson', 'Marketing', 88000.00),
    (109, 'Robert',   'Thomas',   'HR',        78000.00),
    (110, 'Barbara',  'Jackson',  'Sales',     95000.00);
T-SQL — Cursor that walks Sales employees
-- 1. Declare variables to hold each row's columns
DECLARE @EmployeeID  INT,
        @FirstName   NVARCHAR(50),
        @LastName    NVARCHAR(50),
        @Department  NVARCHAR(50),
        @Salary      DECIMAL(10, 2);

-- 2. Declare the cursor
DECLARE employee_cursor CURSOR FOR
    SELECT EmployeeID, FirstName, LastName, Department, Salary
    FROM   Employees
    WHERE  Department = 'Sales';

-- 3. Open it (this is when the SELECT actually runs)
OPEN employee_cursor;

-- 4. Prime the loop with the first FETCH
FETCH NEXT FROM employee_cursor
    INTO @EmployeeID, @FirstName, @LastName, @Department, @Salary;

-- 5. Process each row, fetching the next one at the bottom
WHILE @@FETCH_STATUS = 0
BEGIN
    PRINT 'EmployeeID: ' + CAST(@EmployeeID AS VARCHAR(10)) +
          '  Name: '    + @FirstName + ' ' + @LastName +
          '  Dept: '    + @Department +
          '  Salary: '  + CAST(@Salary AS VARCHAR(10));

    FETCH NEXT FROM employee_cursor
        INTO @EmployeeID, @FirstName, @LastName, @Department, @Salary;
END

-- 6. Always close and deallocate
CLOSE      employee_cursor;
DEALLOCATE employee_cursor;
OUTPUT
Messages
Commands completed successfully.
EmployeeID: 101 Name: John Doe Dept: Sales Salary: 50000.00
EmployeeID: 103 Name: Sam Brown Dept: Sales Salary: 55000.00
EmployeeID: 105 Name: Bob White Dept: Sales Salary: 52000.00
EmployeeID: 107 Name: Patricia Taylor Dept: Sales Salary: 65000.00
EmployeeID: 110 Name: Barbara Jackson Dept: Sales Salary: 95000.00

That cursor walked 5 rows. For 5 rows it's fine — for 5 million it would be painful. Here's the same job done set-based, in a single statement:

T-SQL — Same result, set-based
SELECT 'EmployeeID: ' + CAST(EmployeeID AS VARCHAR(10)) +
       '  Name: '    + FirstName + ' ' + LastName +
       '  Dept: '    + Department +
       '  Salary: '  + CAST(Salary AS VARCHAR(10)) AS Line
FROM   Employees
WHERE  Department = 'Sales';

Same output, no cursor, no DECLARE/OPEN/FETCH/CLOSE/DEALLOCATE, and the SQL Server optimizer is free to use indexes, parallelism, and other tricks. Whenever you find yourself reaching for a cursor, run this same mental check first.

The cursor type controls how the cursor sees its data over its lifetime — whether changes made by other transactions are visible, whether you can scroll backward, etc. Pick the cheapest type that meets your needs.

TypeVisibility of changesScrollingPerformance
STATICSnapshot — no changes visible after OPENForward + backwardFast (no change tracking)
DYNAMICAll changes visible (inserts, updates, deletes)Forward + backwardSlowest (constant re-checking)
KEYSETUpdates to non-key columns visible; insert/delete of rows is notForward + backwardMiddle
FAST_FORWARDSnapshot (read-only)Forward onlyFastest
FORWARD_ONLYDefault behaviorForward onlyFast

Syntax

T-SQL — Specify a type
DECLARE my_cursor CURSOR STATIC       FOR SELECT ...;
DECLARE my_cursor CURSOR DYNAMIC      FOR SELECT ...;
DECLARE my_cursor CURSOR KEYSET       FOR SELECT ...;
DECLARE my_cursor CURSOR FAST_FORWARD FOR SELECT ...;
DECLARE my_cursor CURSOR FORWARD_ONLY FOR SELECT ...;
💡 If you must use a cursor, use LOCAL FAST_FORWARD. It's the fastest combination — read-only, forward-only, and scoped to the current batch (so you don't accidentally collide with a globally-named cursor):
DECLARE my_cursor CURSOR LOCAL FAST_FORWARD FOR
    SELECT ...;

By default, cursors are GLOBAL — visible to every batch in the connection until the connection ends. This causes naming collisions and makes procedures fragile. Always declare cursors as LOCAL:

ScopeVisible fromLifetime
GLOBALThe whole connectionUntil DEALLOCATE or session ends
LOCALCurrent batch / procedure / trigger onlyAuto-deallocated when scope exits
T-SQL — Local-scoped cursor (recommended)
DECLARE my_cursor CURSOR LOCAL FAST_FORWARD FOR
    SELECT col1, col2 FROM my_table;

@@FETCH_STATUS

The most important cursor function — tells you what just happened on the most recent FETCH. The WHILE loop's condition almost always uses it.

ValueMeaning
0FETCH succeeded — a row was returned
-1FETCH failed, or there were no more rows (most common reason to exit the loop)
-2The row that was fetched is missing (someone deleted it after the cursor opened — only happens with KEYSET cursors)
⚠️ @@FETCH_STATUS is global to the connection, not to a specific cursor. If you have multiple cursors open, it always reflects the most recent FETCH from any of them. Capture the value into a local variable immediately if you need it later.

@@CURSOR_ROWS

Returns the number of rows currently in the open cursor's result set. The behavior varies by cursor type:

ValueMeaning
Positive numberStatic or keyset cursor: that's the actual row count
-1Dynamic cursor — the count can't be predicted, since rows can be added/removed during iteration
0The cursor is closed, deallocated, or has no rows
Negative number (other than -1)The cursor is being populated asynchronously — partial count

CURSOR_STATUS

Lets you inspect a cursor's state by name — useful in defensive code that needs to verify a cursor is in a usable state before working with it:

T-SQL — Check status
SELECT CURSOR_STATUS('local', 'my_cursor');     -- for LOCAL cursors
SELECT CURSOR_STATUS('global', 'my_cursor');    -- for GLOBAL cursors
Return valueMeaning
1Cursor is open and has at least one row (static, keyset) or may have rows (dynamic)
0Cursor is open but has no rows
-1Cursor is closed
-2Cursor not allocated (e.g. assigned NULL, never opened)
-3A cursor with that name doesn't exist in the requested scope
  1. Try to avoid them. Almost every cursor I've ever rewritten as set-based SQL has been faster.
  2. Always use LOCAL FAST_FORWARD when you do need one — fastest read-only forward-only cursor.
  3. Always pair OPEN with CLOSE + DEALLOCATE, even on errors. Use TRY ... CATCH if there's any chance the loop body raises.
  4. Don't change the underlying table during cursor iteration unless you've thought carefully about which cursor type you're using — it's easy to skip rows or process them twice.
  5. Don't open a cursor inside a loop. If you're tempted to, you almost certainly need a JOIN instead.

Genuine cursor use cases are rarer than they seem, but they exist:

  • Calling a stored procedure once per row, when the procedure has side effects (sending email, calling out to a service, writing to a log).
  • Building strings or aggregating state where each row's processing depends on the previous row's result, in a way that SUM() OVER () or STRING_AGG() can't express.
  • Generating dynamic SQL for each row (rare — usually a sign you should normalize the data instead).

If your case fits one of these, a cursor is fine. If not — keep looking for the set-based answer.

  • A cursor is a database object that lets you process query results one row at a time. The lifecycle is always DECLAREOPENFETCH (in a loop) → CLOSEDEALLOCATE.
  • Use WHILE @@FETCH_STATUS = 0 to drive the loop. Remember: 0 = success, -1 = no more rows, -2 = missing row.
  • Always declare cursors as LOCAL FAST_FORWARD unless you have a specific reason for a different type — it's the fastest and safest combination.
  • Always close and deallocate. CLOSE alone leaves the cursor's definition in memory.
  • Cursors are slow. Before writing one, ask yourself whether the same job can be done with a single set-based SELECT, UPDATE, or MERGE. The answer is usually yes.
  • Use @@FETCH_STATUS, @@CURSOR_ROWS, and CURSOR_STATUS() to inspect cursor state when you need defensive logic.