Database Cursors — When Unclosed Cursors Drain Your Pool
A network blip during FETCH NEXT left 50 cursors open, draining the pool in an hour.
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- A cursor is a database object that lets you iterate over a result set row by row
- Static cursors take a snapshot; dynamic cursors see changes; keyset cursors are a middle ground
- Cursor overhead per row: ~0.5–5µs fetch cost plus network round trips if not server-side
- Most production cursor failures come from unclosed cursors holding locks or filling temp tables
- Biggest mistake: reaching for a cursor when a set-based JOIN, window function, or recursive CTE would do
- Rule: use cursors only when row-level state or ordered side effects are unavoidable
A database cursor is a pointer to a result set that allows you to fetch rows sequentially. The database engine creates a working table (often in tempdb or a temporary segment) that holds the current position and, depending on the cursor type, may store the entire result set or just a keyset.
You can think of it as an iterator over a query result. But unlike a typical programming language iterator, the cursor lives on the database server side and its lifecycle is managed through explicit DECLARE, OPEN, FETCH, CLOSE, and DEALLOCATE commands.
Cursors come in four main flavors, each with different trade-offs between consistency, memory, and freshness:
- Static cursor: Takes a snapshot of the result set at open time. No updates from other sessions are visible. Entire result set is materialized into a temporary table. Safe and consistent, but expensive on large datasets. - Keyset cursor: Materializes only the unique row identifiers (keys) and fetches each row on demand from the underlying tables.
Sees updates to non-key columns but not inserts/deletes from other sessions. Cheaper than static when row size is large. - Dynamic cursor: Sees all changes (inserts, updates, deletes) made by other sessions after the cursor is opened. No materialization — just a pointer to the actual rows.
Most expensive in terms of lock contention and server overhead.
- Forward-only cursor: Can only move forward through the result set. Default in most databases. Does not support SCROLL or FETCH PRIOR.
Imagine you're reading a very long book in a library, but you're only allowed to carry one page at a time to your desk. A cursor is the bookmark that remembers exactly where you are in that book — which page you last picked up — so you can go back for the next one. The database is the library, the result set is the book, and your application is you at the desk. Without the bookmark, you'd have to start from page one every single time.
Most SQL you write is declarative — you describe the shape of the data you want, and the database engine figures out how to get it in one shot. That works beautifully for 95% of use cases. But sometimes you genuinely need to process rows one at a time, carry state between rows, or perform logic that depends on the result of the previous row before you can compute the next one. That's the moment cursors enter the room, and if you don't know how they actually work, they'll silently destroy your application's performance at scale.
Cursors exist to solve a fundamental mismatch: relational databases think in sets, but procedural code — and many real business problems — think in sequences. Consider calculating a running bank balance, applying tiered commission rules where each row's rate depends on the cumulative total so far, or processing a feed of CDC events in strict order. These problems resist pure set-based solutions. Cursors provide a controlled, stateful way to walk through a result set row by row while the database engine manages memory, locking, and position tracking behind the scenes.
By the end of this article you'll understand exactly what happens inside the database engine when you declare a cursor, how the different cursor types (static, keyset, dynamic, forward-only) affect locks, memory, and consistency, how to write production-safe cursor code in both T-SQL and PL/pgSQL, and — critically — how to recognize when a cursor is the wrong tool and what to replace it with. You'll also walk away knowing the three cursor mistakes that silently corrupt data or cripple query performance in prod.
What Is a Database Cursor?
A database cursor is a pointer to a result set that allows you to fetch rows sequentially. The database engine creates a working table (often in tempdb or a temporary segment) that holds the current position and, depending on the cursor type, may store the entire result set or just a keyset.
You can think of it as an iterator over a query result. But unlike a typical programming language iterator, the cursor lives on the database server side and its lifecycle is managed through explicit DECLARE, OPEN, FETCH, CLOSE, and DEALLOCATE commands.
Cursors come in four main flavors, each with different trade-offs between consistency, memory, and freshness:
- Static cursor: Takes a snapshot of the result set at open time. No updates from other sessions are visible. Entire result set is materialized into a temporary table. Safe and consistent, but expensive on large datasets.
- Keyset cursor: Materializes only the unique row identifiers (keys) and fetches each row on demand from the underlying tables. Sees updates to non-key columns but not inserts/deletes from other sessions. Cheaper than static when row size is large.
- Dynamic cursor: Sees all changes (inserts, updates, deletes) made by other sessions after the cursor is opened. No materialization — just a pointer to the actual rows. Most expensive in terms of lock contention and server overhead.
- Forward-only cursor: Can only move forward through the result set. Default in most databases. Does not support
SCROLLorFETCH PRIOR.
-- PL/SQL block nested in package IO_THECODEFORGE.CURSOR_DEMO DECLARE CURSOR emp_cursor IS SELECT employee_id, salary FROM employees WHERE department_id = 50; v_emp_id employees.employee_id%TYPE; v_salary employees.salary%TYPE; BEGIN OPEN emp_cursor; LOOP FETCH emp_cursor INTO v_emp_id, v_salary; EXIT WHEN emp_cursor%NOTFOUND; DBMS_OUTPUT.PUT_LINE('Employee ' || v_emp_id || ' earns ' || v_salary); END LOOP; CLOSE emp_cursor; END; /
Cursor Internals: What Happens When You OPEN a Cursor
When the database executes OPEN cursor_name, a series of operations happen that many developers never consider:
- The SQL associated with the cursor is parsed and optimized again (unless it's already in the shared pool).
- Depending on the cursor type:
- - Static: The database performs a full table or index scan according to the query plan and writes the result rows into a temporary segment (e.g., Oracle's temp tablespace, PostgreSQL's
temp_buffers). - - Keyset: Only the unique key columns of the result set are stored in a temporary structure. At fetch time, each row is looked up by its key from the actual table.
- - Dynamic: No materialization at all. The cursor simply holds a bookmark pointing to the next row based on the current state of the underlying tables.
- The database sets up a cursor context in the session memory, which includes the current position, the statement handle, and any locks acquired.
This initialization cost is typically in the range of 0.1–1 ms per cursor open on modern hardware, but it can spike to seconds if the query is complex or the temp tablespace is slow.
-- Simulate cursor open overhead with different types SET TIMING ON; -- Static cursor (materializes full result) DECLARE CURSOR c1 IS SELECT * FROM large_table; r large_table%ROWTYPE; BEGIN OPEN c1; FETCH c1 INTO r; CLOSE c1; END; / -- Forward-only cursor (no temp table) DECLARE CURSOR c2 IS SELECT /*+ PARALLEL */ * FROM large_table; r large_table%ROWTYPE; BEGIN OPEN c2; FETCH c2 INTO r; CLOSE c2; END; /
READ_ONLY FAST_FORWARD if you don't need scrolling.Row-by-Row vs Bulk Fetch: The Hidden Performance Trap
The biggest performance killer in cursor-based code is the overhead of fetching one row at a time inside a loop, especially when each fetch triggers a round-trip between the application and the database. Even with server-side cursors, each FETCH NEXT incurs context switching and cursor maintenance overhead.
- Oracle:
FETCH cursor BULK COLLECT INTO collection LIMIT batch_size; - PostgreSQL:
DECLARE curs CURSOR FOR SELECT ...; FETCH FORWARD batch_size FROM curs; - SQL Server:
FETCH NEXT FROM cursor INTO @var OFFSET batch_size ROWS FETCH NEXT batch_size ROWS ONLY(or useSET ROWCOUNT).
Bulk fetching reduces the number of round trips from \(N\) (one per row) to \(N / batch_size\). The recommended batch size is typically between 100 and 1000, depending on column width and network latency.
DECLARE TYPE emp_tab_type IS TABLE OF employees%ROWTYPE; emp_tab emp_tab_type; CURSOR emp_cur IS SELECT employee_id, salary FROM employees WHERE department_id = 50; k_batch CONSTANT PLS_INTEGER := 100; BEGIN OPEN emp_cur; LOOP FETCH emp_cur BULK COLLECT INTO emp_tab LIMIT k_batch; FOR i IN 1..emp_tab.COUNT LOOP -- Process each row (stateful logic here) DBMS_OUTPUT.PUT_LINE(emp_tab(i).employee_id || ' => ' || emp_tab(i).salary); END LOOP; EXIT WHEN emp_tab.COUNT < k_batch; END LOOP; CLOSE emp_cur; END; /
- Single-row fetch = narrow nozzle: high pressure but low volume per unit time.
- Batch fetch = wide nozzle: lower pressure per row but vastly higher throughput.
- The batch size is the nozzle width — too wide (high LIMIT) may use excessive memory; too narrow (low LIMIT) defeats the purpose.
fetch() call can be a network round trip.When Cursors Hurt: The Three Anti-Patterns to Avoid
Most cursor overhead in production comes from using them where a set-based solution would do the job better. Here are three concrete anti-patterns that silently degrade performance:
Anti-pattern 1: Running aggregate computations inside a cursor loop. Example: iterating over orders to calculate a running total when a window function SUM(amount) OVER (ORDER BY order_date) would do it in one pass.
Anti-pattern 2: Updating the same table you are reading from with a cursor. This often leads to deadlocks or consistent-read violations. Prefer MERGE or multi-table UPDATE with subqueries.
Anti-pattern 3: Cursors inside triggers. Row-level triggers already operate row-by-row. Adding a cursor inside a trigger multiplies context switching and can cause mutating-table errors in Oracle.
The common root: developers fall back to cursor-based thinking because it resembles the procedural loops they know from traditional programming languages.
-- Anti-pattern 1: Cursor with running total (slow) DECLARE CURSOR c1 IS SELECT order_id, amount FROM orders ORDER BY order_date; v_running NUMBER := 0; v_ord orders%ROWTYPE; BEGIN OPEN c1; LOOP FETCH c1 INTO v_ord; EXIT WHEN c1%NOTFOUND; v_running := v_running + v_ord.amount; DBMS_OUTPUT.PUT_LINE(v_ord.order_id || ' total: ' || v_running); END LOOP; CLOSE c1; END; / -- Better: set-based with window function SELECT order_id, amount, SUM(amount) OVER (ORDER BY order_date) AS running_total FROM orders;
Production-Safe Cursor Patterns in PL/SQL and PL/pgSQL
When a cursor is justified (e.g., row-by-row business logic with state that cannot be expressed in SQL), you need to follow production patterns that prevent leaks and performance degradation.
Pattern 1: Parameterized cursors Always pass filter values as bind variables, not string concatenation. This avoids SQL injection and allows cursor reuse.
Pattern 2: Limit batch size with BULK COLLECT ... LIMIT As discussed, this is non-optional for any cursor processing thousands of rows.
Pattern 3: Always close and deallocate In PL/SQL, use a dedicated CLOSE statement. In some databases (e.g., PostgreSQL), a cursor is automatically closed at end of transaction, but it's safer to CLOSE explicitly.
Pattern 4: Use FOR UPDATE cursor only when needed If you need to update the current row, declare a cursor with FOR UPDATE OF column to lock only the relevant row. Avoid FOR UPDATE on read-only cursors.
Pattern 5: Prefer implicit cursors when possible Implicit cursors (like SELECT INTO or FOR rec IN (query) LOOP) are managed automatically and often more efficient because they use bulk collect internally.
CREATE OR REPLACE PACKAGE BODY io_thecodeforge.cursor_safe AS PROCEDURE process_commissions(p_dept_id IN NUMBER) IS CURSOR emp_cur(p_dept NUMBER) IS SELECT employee_id, salary FROM employees WHERE department_id = p_dept FOR UPDATE OF commission_pct; -- only lock necessary column TYPE emp_tab_type IS TABLE OF emp_cur%ROWTYPE; emp_tab emp_tab_type; k_batch CONSTANT PLS_INTEGER := 500; BEGIN OPEN emp_cur(p_dept_id); LOOP FETCH emp_cur BULK COLLECT INTO emp_tab LIMIT k_batch; FOR i IN 1..emp_tab.COUNT LOOP -- complex commission calculation based on cumulative thresholds UPDATE employees SET commission_pct = compute_commission(emp_tab(i).salary, i * k_batch) WHERE CURRENT OF emp_cur; END LOOP; EXIT WHEN emp_tab.COUNT < k_batch; END LOOP; CLOSE emp_cur; END; END; /
FOR UPDATE clause on a cursor locks rows as they are fetched, not at open time. This reduces lock duration compared to FOR UPDATE at the query level. Use DECLARE curs CURSOR FOR SELECT ... FOR UPDATE; for interactive updates.Cursor Lifecycle & Resource Leaks: Why Your DBA Will Hunt You Down
You opened a cursor and forgot to close it. That's not a slip-up—it's a memory leak that'll crater your database. Every open cursor consumes a private SQL area in the shared pool. Leave enough of them dangling, and you'll starve other sessions of memory, trigger ORA-01000 errors, or force a hard parse storm when the database hits its CURSOR_SPACE_FOR_SESSION limit.
Here's the lifecycle: DECLARE, OPEN, FETCH (maybe loop), CLOSE. Forgetting any step—especially CLOSE—is the classic junior mistake. In production, always wrap your cursor operations in a block that guarantees cleanup. PL/SQL's implicit cursors handle this, but explicit ones? You're the janitor.
Why this matters in the real world: A batch job that opens 10,000 cursors across 50 threads without closing them will bring down a production Oracle instance in under 90 seconds. Your DBA's first call isn't your manager. It's you. Don't make that call happen.
// io.thecodeforge — database tutorial DECLARE CURSOR cur_orders IS SELECT order_id, total FROM orders WHERE status = 'PENDING'; rec_orders cur_orders%ROWTYPE; BEGIN OPEN cur_orders; LOOP FETCH cur_orders INTO rec_orders; EXIT WHEN cur_orders%NOTFOUND; -- process order UPDATE orders SET status = 'PROCESSED' WHERE order_id = rec_orders.order_id; END LOOP; CLOSE cur_orders; -- MUST close, or you leak resources EXCEPTION WHEN OTHERS THEN IF cur_orders%ISOPEN THEN CLOSE cur_orders; -- always close in exception handler END IF; RAISE; END;
Cursor Variables (Ref Cursors): When Dynamic SQL Demands Portability
You need to switch which query a cursor executes at runtime. Maybe the user picks a report type, or you're passing a result set between procedures. That's where ref cursors—cursor variables—save your neck. They're not tied to a static SQL statement at compile time. You OPEN them with a string or a dynamic query.
Why you'd risk this: Static cursors are rigid. Your monthly sales report needs different filters depending on the region. Instead of writing five near-identical cursors, define one weakly-typed ref cursor, build the SQL string dynamically, and OPEN it. The cost? More complexity and zero compile-time checking. One typo in your WHERE clause and it blows up at runtime.
Production reality: Ref cursors are the backbone of ORM layers and report engines. They're also the #1 source of SQL injection if you concatenate user input. Never do that. Use bind variables even in dynamic SQL. Your database's shared pool—and your security team—will love you.
// io.thecodeforge — database tutorial CREATE OR REPLACE FUNCTION get_region_sales(p_region VARCHAR2) RETURN SYS_REFCURSOR IS cur_sales SYS_REFCURSOR; v_sql VARCHAR2(200); BEGIN -- Build dynamic SQL: bind variable prevents injection v_sql := 'SELECT sale_date, amount ' || 'FROM sales WHERE region = :1 ' || 'ORDER BY sale_date DESC'; -- OPEN ref cursor with bind variable OPEN cur_sales FOR v_sql USING p_region; RETURN cur_sales; -- caller closes the cursor END; / -- Caller fetches and must close VARIABLE cur_ref REFCURSOR; EXEC :cur_ref := get_region_sales('EMEA'); PRINT cur_ref;
Cursor-Based Pagination: Why OFFSET Is a Performance Liar
You wrote 'OFFSET 10000 LIMIT 20' expecting fast pagination on a web UI. Now your page loads in 6 seconds. Congratulations—you've just scanned 10,020 rows the database will never return. OFFSET is a liar. It reads and discards rows. For large datasets, that's catastrophic.
Cursor-based pagination—key-based or seek pagination—fixes this. You don't skip rows. You start after the last row from the previous page. Think 'WHERE id > last_seen_id LIMIT 20'. That uses the index directly, reads exactly 20 rows, and the query plan stays constant regardless of page number. Page 1,000 costs the same as page 1. No scan, no discard, no pain.
When to use it: Any infinite scroll, activity feed, or API endpoint returning sorted result sets. You need a unique, sortable column—usually the primary key or a composite index. Bulk fetchers love this because you're not fetching rows you'll throw away. Your app's p95 latency drops from seconds to milliseconds. Don't tell your product manager how easy it was.
// io.thecodeforge — database tutorial -- Page 1: no cursor, first 20 transactions SELECT transaction_id, user_id, amount, created_at FROM transactions WHERE created_at >= '2024-01-01' ORDER BY created_at, transaction_id FETCH NEXT 20 ROWS ONLY; -- Page N: use last seen (created_at, transaction_id) from previous page SELECT transaction_id, user_id, amount, created_at FROM transactions WHERE (created_at, transaction_id) > (:last_created_at, :last_transaction_id) AND created_at >= '2024-01-01' ORDER BY created_at, transaction_id FETCH NEXT 20 ROWS ONLY; -- Uses composite index on (created_at, transaction_id) — instant
Forward-Only Cursors: The Fastest Way to Stream 10M Rows Without Blowing Up Memory
Most developers assume cursors are memory hogs. That's true of scrollable cursors, which buffer rows to support random access and backward scrolling. But the default in PostgreSQL, SQL Server, and Oracle's implicit cursors is forward-only. That means no buffering, no spooling, no materialization. The server streams rows directly to your client as fast as the network allows.
Why does this matter? Because forward-only is the difference between a query returning results in 200ms and one that takes 10 seconds just to fill a temp table. If you don't need to jump backward or count rows before reading them, don't pay for that luxury. Every database cursor API I've seen defaults to forward-only for a reason: it's the path of least resource contention.
In production, always check your cursor declaration. Adding SCROLL in Oracle or INSENSITIVE in SQL Server turns a firehose into a bucket brigade. You want the firehose. Query pagination with forward-only cursors keyset-based? That's a billion-row loop that never hits disk. The rule: forward-only by default, scrollable only when you can justify the cost with a real use case.
// io.thecodeforge — database tutorial -- Forward-only cursor for keyset pagination -- No SCROLL, no buffering. Just raw streaming. DECLARE cur CURSOR FORWARD_ONLY FOR SELECT id, payload FROM events WHERE id > :last_seen_id -- keyset, not offset ORDER BY id FETCH NEXT 1000 ROWS ONLY; OPEN cur; FETCH NEXT FROM cur INTO @id, @payload; WHILE @@FETCH_STATUS = 0 BEGIN -- Process one row, then stream the next EXEC sp_handle_event @id, @payload; FETCH NEXT FROM cur INTO @id, @payload; END CLOSE cur; DEALLOCATE cur;
Remarks on Cursors: The Skeleton Key Hidden in Every DBA's Toolbox
Most devs treat remarks like throwaway comments. In the cursor world, remarks can save your production environment from a late-night pager. A cursor remark is metadata attached to the server-side cursor that tells the optimizer and the DBA exactly what your code intends to do. In Oracle, it's a REM statement inside the cursor declaration. In SQL Server, it's a label on the cursor variable. Sounds trivial until you realize it's the only way to trace a runaway cursor back to the exact query and session.
Here's the ugly truth: when your production database runs out of tempdb space at 3 AM, your DBA will see a thousand cursor declarations in sp_who2. They won't know which one is yours, which one is leaking, or which one is a forgotten open cursor from a long-dead connection. A remark — something as simple as '/ ACCT_BAL_CALC /' — turns a mystery into a grep target. It's the difference between a two-hour war room and a two-minute fix.
Use remarks to annotate high-concurrency cursors, especially those tied to batch jobs. Mark the cursor's purpose, the module, and the JIRA ticket. Your future self and your DBA will thank you when the next incident review rolls around.
// io.thecodeforge — database tutorial -- Oracle: REM statement attaches to cursor metadata DECLARE CURSOR c_invoice IS -- REM: Monthly batch billing (JIRA-4421) SELECT invoice_id, total FROM invoices WHERE status = 'PENDING' FOR UPDATE; BEGIN OPEN c_invoice; LOOP FETCH c_invoice INTO v_id, v_total; EXIT WHEN c_invoice%NOTFOUND; UPDATE invoices SET status = 'PROCESSED' WHERE CURRENT OF c_invoice; END LOOP; CLOSE c_invoice; END; -- SQL Server: label via cursor_name DECLARE monthly_bill_cursor CURSOR FOR SELECT invoice_id, total FROM invoices WHERE status = 'PENDING' ORDER BY due_date; OPEN monthly_bill_cursor; FETCH NEXT FROM monthly_bill_cursor INTO @id, @total; WHILE @@FETCH_STATUS = 0 BEGIN UPDATE invoices SET status = 'PROCESSED' WHERE CURRENT OF monthly_bill_cursor; FETCH NEXT FROM monthly_bill_cursor INTO @id, @total; END CLOSE monthly_bill_cursor; DEALLOCATE monthly_bill_cursor;
Forward-Only Cursors: The Fastest Path Through a Million Rows
A forward-only cursor is the most efficient cursor type for streaming large result sets because it forces the database to fetch rows sequentially without storing previous results. Unlike scrollable cursors that allow random access and require a temporary buffer, forward-only cursors read each row once and discard it after move. This eliminates memory overhead and avoids the hidden cost of snapshots. Use forward-only when you need to process all rows exactly once—exporting data, batch updates, or feeding a downstream system. The performance gain is linear: reading 10M rows with a forward-only cursor can be 5–10x faster than a scrollable cursor because the database never materializes the full result set. Many drivers default to forward-only; always check your client library settings to ensure you haven't accidentally enabled scrollability.
// io.thecodeforge — database tutorial -- PostgreSQL: DECLARE with NO SCROLL forces forward-only DECLARE cur_export CURSOR NO SCROLL FOR SELECT id, payload FROM large_table WHERE processed = FALSE; MOVE FORWARD 1 FROM cur_export; FETCH NEXT FROM cur_export INTO rec; LOOP EXIT WHEN NOT FOUND; -- process rec FETCH NEXT FROM cur_export INTO rec; END LOOP; CLOSE cur_export;
Verification Cursors: Validate Before Mutation to Prevent Data Corruption
A verification cursor is a pre-check pattern that scans data before performing destructive updates. Instead of trusting an UPDATE or DELETE blindly, open a verification cursor first, evaluate each row against your business rules, log violations, and abort if anomalies exceed a threshold. This guards against silent data corruption caused by bad application state, broken joins, or stale caches. Use verification cursors in batch jobs, schema migrations, or data cleanup tasks where one wrong WHERE clause could corrupt millions of rows. The pattern is simple: open a cursor, FETCH a sample, validate invariants, then either proceed or roll back. This adds a small overhead—typically less than 5%—but prevents catastrophic failures that take days to reverse. Always close the verification cursor before starting the actual mutation to avoid holding locks across both scans.
// io.thecodeforge — database tutorial DECLARE cur_verify CURSOR FOR SELECT id, account_balance FROM accounts WHERE status = 'active'; OPEN cur_verify; FETCH NEXT FROM cur_verify INTO v_id, v_balance; WHILE FOUND LOOP IF v_balance < 0 THEN RAISE EXCEPTION 'Negative balance found: %', v_id; END IF; FETCH NEXT FROM cur_verify INTO v_id, v_balance; END LOOP; CLOSE cur_verify; -- Safe to proceed UPDATE accounts SET status = 'frozen' WHERE status = 'active';
Summary: When to Use (and Not Use) Database Cursors
Database cursors are not inherently evil — they are a precise tool for row-by-row operations that batch processing cannot handle. Use cursors when you need to process each row with complex logic that depends on previous rows, when you must return a large result set incrementally to avoid memory pressure, or when dynamic SQL demands portable result-set handling via ref cursors. Avoid cursors when a single UPDATE, DELETE, or bulk collect with FORALL can accomplish the same work orders of magnitude faster. The hidden cost is context switching between the database engine and your procedural code — each FETCH round-trips across the server. Production-safe patterns always close cursors in exception handlers, use FOR UPDATE NOWAIT only when locking is required, and prefer forward-only cursors for streaming. The key takeaway: cursors are the scalpel, not the sledgehammer — use them for precision work, not bulk demolition.
// io.thecodeforge — database tutorial -- When to choose a cursor vs. bulk operation -- Use cursor: row-by-row dependency, streaming, dynamic SQL -- Use bulk: set-based update, insert from select -- Anti-pattern: cursor inside transaction that could be a single UPDATE
Summary: Cursor Lifecycle Rules That Prevent Leaks
Every cursor you open must be closed — no exceptions. Database cursors consume server-side memory, locks, and temporary storage. Resource leaks happen when exceptions are thrown between OPEN and CLOSE, or when developers forget that implicit cursors (like FOR loops) auto-close while explicit cursors do not. The production-safe pattern is: DECLARE cursor, OPEN, FETCH in loop with exception block that CLOSEs in both success and failure paths. Use SYS_REFCURSOR for dynamic SQL when you need to return result sets to client applications, but remember that these also must be closed by the consumer. Forward-only cursors with FAST_FETCH minimize memory but still require proper lifecycle management. Your DBA will hunt you down if cursors are left open — each open cursor consumes a database connection slot and can block DDL operations. Always wrap cursor logic in a dedicated procedure with EXCEPTION block that guarantees CLOSE via a nested block or a GOTO cleanup section.
// io.thecodeforge — database tutorial DECLARE CURSOR c IS SELECT id FROM orders; v_id NUMBER; BEGIN OPEN c; BEGIN LOOP FETCH c INTO v_id; EXIT WHEN c%NOTFOUND; -- process END LOOP; EXCEPTION WHEN OTHERS THEN CLOSE c; -- critical! RAISE; END; CLOSE c; -- normal path END;
Cursor vs Set-Based Operations: Performance
Database cursors process rows one at a time, while set-based operations (e.g., UPDATE, INSERT...SELECT) work on entire result sets. The performance difference is dramatic: set-based operations leverage the database engine's optimizer, indexes, and parallel execution, often completing in milliseconds what a cursor might take minutes. For example, updating salaries for all employees in a department can be done with a single UPDATE statement, whereas a cursor would loop through each row, issuing individual updates. In PostgreSQL, a set-based update like UPDATE employees SET salary = salary * 1.1 WHERE department_id = 10 is atomic and efficient. The same logic in a cursor would require explicit looping, context switches, and multiple round trips. The rule of thumb: use set-based operations whenever possible. Cursors are only justified when row-by-row processing is unavoidable, such as when calling external APIs or performing complex business logic that cannot be expressed in SQL. Even then, consider bulk operations (e.g., BULK COLLECT in Oracle) to minimize context switches.
-- Set-based: fast and efficient UPDATE employees SET salary = salary * 1.1 WHERE department_id = 10; -- Cursor-based: slow and resource-intensive DECLARE CURSOR emp_cur IS SELECT employee_id, salary FROM employees WHERE department_id = 10; v_emp_id employees.employee_id%TYPE; v_salary employees.salary%TYPE; BEGIN OPEN emp_cur; LOOP FETCH emp_cur INTO v_emp_id, v_salary; EXIT WHEN emp_cur%NOTFOUND; UPDATE employees SET salary = v_salary * 1.1 WHERE employee_id = v_emp_id; END LOOP; CLOSE emp_cur; END;
Implicit vs Explicit Cursors in Oracle and PostgreSQL
Implicit cursors are automatically created by the database for single-row queries (SELECT INTO) or DML statements. They require no explicit OPEN, FETCH, or CLOSE. Explicit cursors give the programmer control over the lifecycle and are used for multi-row queries. In Oracle, implicit cursors are efficient for single-row operations, but they raise NO_DATA_FOUND if no rows are returned. Explicit cursors allow fetching multiple rows and handling end-of-data gracefully. In PostgreSQL, implicit cursors are used in FOR loops (e.g., FOR rec IN SELECT * FROM table LOOP ... END LOOP), while explicit cursors are declared with DECLARE cur CURSOR FOR .... PostgreSQL's implicit cursors are actually implemented as explicit cursors under the hood but with automatic cleanup. The choice between implicit and explicit depends on control needs: use implicit for simple loops and single-row fetches; use explicit when you need to open a cursor, fetch in batches, or pass it as a ref cursor. In Oracle, explicit cursors give you the ability to use BULK COLLECT for performance. In PostgreSQL, explicit cursors are useful for scrollable cursors or when you need to fetch rows across function calls.
-- Oracle: Implicit cursor (single row) SELECT salary INTO v_salary FROM employees WHERE employee_id = 100; -- Oracle: Explicit cursor (multi-row) DECLARE CURSOR emp_cur IS SELECT employee_id, salary FROM employees; v_emp_id employees.employee_id%TYPE; v_salary employees.salary%TYPE; BEGIN OPEN emp_cur; LOOP FETCH emp_cur INTO v_emp_id, v_salary; EXIT WHEN emp_cur%NOTFOUND; -- process row END LOOP; CLOSE emp_cur; END; -- PostgreSQL: Implicit cursor in FOR loop DO $$ DECLARE rec RECORD; BEGIN FOR rec IN SELECT employee_id, salary FROM employees LOOP -- process row END LOOP; END $$; -- PostgreSQL: Explicit cursor DO $$ DECLARE cur CURSOR FOR SELECT employee_id, salary FROM employees; rec RECORD; BEGIN OPEN cur; LOOP FETCH cur INTO rec; EXIT WHEN NOT FOUND; -- process row END LOOP; CLOSE cur; END $$;
Cursor FOR Loop: When They Are Inevitable
A cursor FOR loop automatically opens a cursor, fetches each row, and closes the cursor when done. It is the safest way to iterate over a result set because it eliminates the risk of forgetting to close the cursor. In Oracle, the syntax is FOR rec IN cursor_name LOOP ... END LOOP;. In PostgreSQL, it's FOR rec IN SELECT ... LOOP ... END LOOP;. Cursor FOR loops are inevitable when you need to perform row-by-row operations that cannot be expressed as set-based operations, such as calling a stored procedure for each row, sending notifications, or performing complex calculations that depend on previous rows. They are also useful when you need to break out of the loop early based on a condition. However, they still suffer from performance overhead compared to set-based operations. To mitigate this, consider using bulk operations inside the loop (e.g., collecting rows into an array and processing them in batches). In Oracle, you can use BULK COLLECT with LIMIT to fetch in chunks. In PostgreSQL, you can use array aggregation or temporary tables. Despite the overhead, cursor FOR loops are often the clearest and most maintainable solution for inherently row-by-row problems.
-- Oracle: Cursor FOR loop DECLARE CURSOR emp_cur IS SELECT employee_id, salary FROM employees; BEGIN FOR rec IN emp_cur LOOP -- process each row DBMS_OUTPUT.PUT_LINE('Employee ' || rec.employee_id || ' has salary ' || rec.salary); END LOOP; END; -- PostgreSQL: Cursor FOR loop (implicit) DO $$ DECLARE rec RECORD; BEGIN FOR rec IN SELECT employee_id, salary FROM employees LOOP RAISE NOTICE 'Employee % has salary %', rec.employee_id, rec.salary; END LOOP; END $$; -- Oracle: BULK COLLECT inside FOR loop for performance DECLARE CURSOR emp_cur IS SELECT employee_id, salary FROM employees; TYPE emp_tab IS TABLE OF emp_cur%ROWTYPE; l_emps emp_tab; BEGIN OPEN emp_cur; LOOP FETCH emp_cur BULK COLLECT INTO l_emps LIMIT 100; EXIT WHEN l_emps.COUNT = 0; FOR i IN 1..l_emps.COUNT LOOP -- process batch END LOOP; END LOOP; CLOSE emp_cur; END;
The 3 AM Incident: Unclosed Cursor Causes Connection Pool Exhaustion
FETCH NEXT call. The PL/SQL block that held the cursor was aborted, but the cursor itself remained open on the server because the implicit rollback didn't release it in all database configurations. Oracle, for example, holds cursors open until the user session explicitly closes them or the session ends. The next iteration of the job created a new cursor without closing the previous one, and over an hour, 50 connections each held an open cursor — eventually blocking new connections.ALTER SYSTEM SET cursor_ingnore_timeout = 300 in Oracle, or idle_in_transaction_session_timeout in PostgreSQL). 3. Set a MAX_OPEN_CURSORS limit per session in the connection pool configuration and added monitoring to alert when threshold crossed.- Always close cursors in a finally block — and handle the case where the finally block itself could fail.
- Set server-side timeouts for idle cursors so a leaked cursor doesn't survive indefinitely.
- Monitor open cursor count per session as a standard production health metric.
SCROLL when only forward-only is needed. Run EXPLAIN ANALYZE on the SELECT statement inside the cursor — often the query itself is slow, not the cursor. Also check for row-by-row processing that could be replaced by a set-based operation.v$sesstat 'opened cursors current' count (Oracle) or pg_stat_activity with large query field (Postgres). An unclosed static cursor stores the entire result set in temp tables — kill the session or close the cursor manually.maxOpenCursors per connection is too low or cursor leak exists. Query v$open_cursor (Oracle) to find which SQL is not being closed. In PostgreSQL, use pg_stat_activity to see long-running queries. Temporarily increase limit, then fix the leak.FOR UPDATE with a cursor, ensure the transaction isolation level is REPEATABLE READ or SERIALIZABLE to avoid phantom reads. Use WITH CHECK OPTION if updating via cursor.SELECT sid, value FROM v$sesstat WHERE statistic# = (SELECT statistic# FROM v$statname WHERE name = 'opened cursors current') ORDER BY value DESC;SELECT sql_text FROM v$open_cursor WHERE sid = <high_sid>;EXPLAIN (ANALYZE, BUFFERS) SELECT ... (the cursor query)Check if indexes are used; if not, add indexes on WHERE and ORDER BY columns.In PostgreSQL: SELECT * FROM pg_prepared_statements WHERE statement LIKE '%DECLARE%';Change cursor to `NO SCROLL` or use keyset cursor if possible.| Cursor Type | Consistency Guarantee | Temp Space Usage | Update Visibility | Best Use Case |
|---|---|---|---|---|
| Static | Snapshot at open time | High (full result set) | No changes visible | Reporting, data export, small-mid result sets |
| Keyset | Views updates to non-key columns | Low (only keys) | No inserts/deletes from others | Interactive scrolling with partial updates |
| Dynamic | Sees all changes (including inserts) | None (live query) | Full visibility | Live monitoring dashboards |
| Forward-only (read-only) | None (no scroll, no updates) | None (or temp for static) | N/A | Default for most batch processing; fast & light |
| File | Command / Code | Purpose |
|---|---|---|
| io_thecodeforge_basic_cursor.sql | DECLARE | What Is a Database Cursor? |
| io_thecodeforge_cursor_internals.sql | SET TIMING ON; | Cursor Internals |
| io_thecodeforge_bulk_fetch.sql | DECLARE | Row-by-Row vs Bulk Fetch |
| io_thecodeforge_cursor_antipattern.sql | DECLARE | When Cursors Hurt |
| io_thecodeforge_production_cursor.sql | CREATE OR REPLACE PACKAGE BODY io_thecodeforge.cursor_safe AS | Production-Safe Cursor Patterns in PL/SQL and PL/pgSQL |
| CursorLifecycle_GuaranteedCleanup.sql | DECLARE | Cursor Lifecycle & Resource Leaks |
| RefCursor_DynamicReport.sql | CREATE OR REPLACE FUNCTION get_region_sales(p_region VARCHAR2) | Cursor Variables (Ref Cursors) |
| CursorPagination_SeekMethod.sql | SELECT transaction_id, user_id, amount, created_at | Cursor-Based Pagination |
| ForwardOnlyPagination.sql | DECLARE cur CURSOR FORWARD_ONLY FOR | Forward-Only Cursors |
| CursorWithRemarks.sql | DECLARE | Remarks on Cursors |
| ForwardOnlyCursor.sql | DECLARE cur_export CURSOR NO SCROLL FOR | Forward-Only Cursors |
| VerificationCursor.sql | DECLARE cur_verify CURSOR FOR | Verification Cursors |
| SafeCursorLifecycle.sql | DECLARE | Summary |
| cursor_vs_set_based.sql | UPDATE employees | Cursor vs Set-Based Operations |
| implicit_vs_explicit.sql | SELECT salary INTO v_salary FROM employees WHERE employee_id = 100; | Implicit vs Explicit Cursors in Oracle and PostgreSQL |
| cursor_for_loop.sql | DECLARE | Cursor FOR Loop |
Key takeaways
Interview Questions on This Topic
Explain the difference between a static cursor and a dynamic cursor. When would you use each?
What is the impact of leaving a cursor open in a production database? How would you detect and fix it?
OPEN_CURSORS parameter) and blocks new SQL execution.
Detection: In Oracle, query v$open_cursor to find cursors not closed by a session. In PostgreSQL, use pg_stat_activity and look for queries starting with DECLARE. In SQL Server, SELECT * FROM sys.dm_exec_cursors(0).
Fix: Ensure explicit closing of the cursor in all code paths (including exception blocks). Set a session-level timeout or use connection pool settings that automatically close orphaned cursors. Kill hanging sessions if necessary.You have a stored procedure that uses a cursor to update 50,000 rows one at a time. It's taking 30 minutes. How would you optimize it?
UPDATE statement.
2. Add bulk collect: If the logic requires row-level state, use BULK COLLECT INTO ... LIMIT 500 to batch fetches and then update in bulk using FORALL or a set-based merge.
3. Reduce round trips: Ensure the cursor is server-side (PL/SQL block) not client-side. Use WHERE CURRENT OF for each update row.
I'd typically see a reduction from 30 minutes to under 1 minute with bulk collect and a batch size of 500.What is a mutating table error and how does it relate to cursors?
employees that loops through all employees in the same table using a cursor would fail because the table is in a mutating state. The fix is to use a compound trigger (Oracle 11g+), a statement-level trigger with a collection, or avoid accessing the mutating table entirely.Frequently Asked Questions
Use a cursor when you need to maintain state across rows (e.g., running calculations that depend on previous row's result), perform row-level side effects that cannot be expressed as SQL (like calling an external API per row), or when you need precise control over update order in a transactional workflow. If your logic can be expressed in a single SQL statement with window functions, recursive CTEs, or conditional aggregation, you don't need a cursor.
An implicit cursor is automatically created by the database for SQL statements that return a single row (SELECT INTO) or for DML operations. It's managed without DECLARE/OPEN/CLOSE. Explicit cursors give you more control — you can declare a name, parameterize, use bulk collect, and scroll. For multi-row processing, always use an explicit cursor with bulk collect.
It depends on the cursor type and the database. A read-only, forward-only cursor generally does not acquire locks — it uses MVCC (snapshot isolation) to give a consistent view. A FOR UPDATE cursor locks rows as they are fetched (Oracle, SQL Server) or at the table level (some MySQL storage engines). Dynamic cursors may hold shared locks to ensure row presence. Always check your database's documentation for lock behavior.
No — a recursive CTE is itself a set-based solution that replaces many cursor use cases. If you find yourself thinking of a cursor inside a recursive CTE, you have a design problem. Recursive CTEs can handle hierarchical data, running totals, and graph traversals without cursors.
PostgreSQL does not expose open cursors directly via a system view, but you can infer them by looking at active queries: SELECT pid, query, state FROM pg_stat_activity WHERE query LIKE 'DECLARE%' or state = 'active'. Also, the pg_prepared_statements view shows prepared statements that might hold cursor references. For detailed cursor tracking, set log_statement = 'all' and grep for 'DECLARE' in logs (only for debugging).
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
That's SQL Advanced. Mark it forged?
12 min read · try the examples if you haven't