Database Cursors — When Unclosed Cursors Drain Your Pool
A network blip during FETCH NEXT left 50 cursors open, draining the pool in an hour.
- 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
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.
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.
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.
- 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.
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.
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.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.Key takeaways
Common mistakes to avoid
4 patternsForgetting to close the cursor after processing
Using a cursor when a set-based operation would suffice
Updating the same table the cursor is reading from without proper isolation
FOR UPDATE cursor to lock rows as you touch them. Better yet, separate read and write into two distinct statements: first collect keys, then update in bulk.Not using bulk collect when processing large volumes
BULK COLLECT INTO ... LIMIT batch_size. Start with batch size 500 and tune based on row width and available PGA memory.Interview Questions on This Topic
Explain the difference between a static cursor and a dynamic cursor. When would you use each?
Frequently Asked Questions
That's SQL Advanced. Mark it forged?
5 min read · try the examples if you haven't