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.
- 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.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.
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.
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.
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.
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.
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.
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.
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.
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.
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>;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
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