Mid-level 5 min · March 06, 2026

Database Cursors — When Unclosed Cursors Drain Your Pool

A network blip during FETCH NEXT left 50 cursors open, draining the pool in an hour.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
Plain-English First

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 SCROLL or FETCH PRIOR.
io_thecodeforge_basic_cursor.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 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;
/
Output
Employee 120 earns 45000
Employee 121 earns 52000
Employee 122 earns 38000
-- (sample output, fetches all rows in department 50)
Forge Tip:
Always use explicit CURSOR declarations in PL/SQL — they're easier to debug and allow the optimizer to choose between row-by-row and bulk collect with LIMIT.
Production Insight
Static cursors on tables with millions of rows can fill the temp tablespace, causing ORA-01652 or disk full errors.
Always estimate the result set size before opening a static cursor.
Rule: use keyset or forward-only cursors for large result sets to avoid materializing the entire dataset.
Key Takeaway
Cursors are server-side pointers for row-by-row processing.
Their type decides consistency vs performance trade-off.
Pick the smallest capability cursor that meets your need.

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:

  1. The SQL associated with the cursor is parsed and optimized again (unless it's already in the shared pool).
  2. Depending on the cursor type:
  3. - 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).
  4. - 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.
  5. - 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.
  6. 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.

io_thecodeforge_cursor_internals.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 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;
/
Output
Static cursor open: 2.34 seconds (materialized 10M rows in temp)
Forward-only cursor open: 0.08 seconds
Performance Warning:
In SQL Server, the default cursor type is forward-only, read-only, which is fast. But if you declare a static cursor (e.g., with SCROLL_LOCKS), the engine will materialize the entire result set into tempdb. Always specify READ_ONLY FAST_FORWARD if you don't need scrolling.
Production Insight
Opening a static cursor on a 10-million-row table creates a temporary table of similar size.
If your temp tablespace is small and shared with other operations, this can cause 'unable to extend temp segment' errors.
Rule: always test cursor open time with representative data before deploying to production.
Key Takeaway
OPEN triggers parsing and materialization for static/keyset cursors.
Initialization cost is hidden but significant — always measure it.
Know your cursor type: it determines temp space usage.

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.

To mitigate this, every major database supports bulk fetching
  • 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 use SET 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.

io_thecodeforge_bulk_fetch.sqlPLSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
/
Output
120 => 45000
121 => 52000
122 => 38000
... (printed in batches of 100)
Mental Model: Think of a Cursor as a Water Hose
  • 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.
Production Insight
In a Java app using JDBC cursor, each fetch() call can be a network round trip.
A batch of 1000 rows in Oracle reduces round trips by 99.9% compared to single-row fetch.
Rule: always use bulk collect with a LIMIT when processing large cursor sets in production PL/SQL.
Key Takeaway
Single-row fetch is for debugging, not production.
Bulk fetch with LIMIT cuts overhead by orders of magnitude.
Batch size of 500 is a safe starting point for most use cases.

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.

io_thecodeforge_cursor_antipattern.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 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;
Output
Anti-pattern output (not shown) takes 12 seconds on 100k orders.
Window function: 0.2 seconds.
Warning:
In Oracle, if you create a cursor inside a trigger and the trigger fires on the same table you are querying, you get a mutating-table error (ORA-04091). Even if the query is on a different table, a cursor inside a row trigger is almost always a design mistake.
Production Insight
A cursor that updates the same table it reads from can cause deadlocks under concurrent load.
We saw a 15-minute batch job reduced to 30 seconds by replacing a cursor with a single MERGE statement.
Rule: if you see a cursor inside a cursor, you are probably doing something wrong.
Key Takeaway
Cursors are not inherently evil, but they are overused.
Fight the reflex to write cursors — first ask if a window function, CTE, or set-based DML can do it.
If you must use a cursor, keep it simple, batch it, and close it fast.

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.

io_thecodeforge_production_cursor.sqlPLSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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;
/
Output
(No output — procedure runs silently; reduces cursor duration by using batch update)
Pro Tip:
In PostgreSQL, the 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.
Production Insight
A parameterized cursor can be reused across multiple calls by the same session, avoiding reparsing.
But beware of cursor pin S deadlocks in Oracle when multiple sessions hold the same cursor concurrently.
Rule: always CLOSE and DEALLOCATE cursors immediately after use, even in success paths.
Key Takeaway
Production cursors need parameterization, batching, and explicit closing.
Implicit cursors are often safer and faster for simple loops.
FOR UPDATE locks only when you fetch — keep fetch intervals short.
● Production incidentPOST-MORTEMseverity: high

The 3 AM Incident: Unclosed Cursor Causes Connection Pool Exhaustion

Symptom
Database connections in the pool slowly crept up until all 50 were consumed. Application logs showed 'Cannot acquire connection from pool' with timeout errors. The database side showed many sessions in 'idle in transaction' state with open cursors.
Assumption
The team assumed that since the cursor was declared inside a try block, the finally clause that closed it would always run. They didn't account for the fact that a network disconnect during cursor fetch caused the JDBC driver to throw an exception before the close statement executed — and the application code didn't close the cursor in the exception handler.
Root cause
A network blip between the application server and the database caused an IO error during the 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.
Fix
1. Surrounded the cursor fetch block with a separate try-catch that explicitly closes the cursor in both success and failure paths. 2. Added a database-side idle cursor timeout (e.g., 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.
Key lesson
  • 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.
Production debug guideSymptom → action guide for common cursor-related production issues4 entries
Symptom · 01
Slow response time for a stored procedure that uses a cursor; query takes minutes instead of seconds.
Fix
Check if the cursor is using 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.
Symptom · 02
Database server memory usage grows over time; eventually runs out of memory.
Fix
Identify sessions with high 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.
Symptom · 03
Application throws 'ORA-01000: maximum open cursors exceeded' or similar.
Fix
Check connection pool settings: 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.
Symptom · 04
Data inconsistency: rows are duplicated or missing when using a cursor to update and read.
Fix
Used dynamic cursor without appropriate locking? If you rely on 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.
★ Quick Cursor Debugging Cheat SheetCommands to diagnose cursor issues in Oracle, PostgreSQL, and SQL Server
Open cursor count is high
Immediate action
Identify the session and query causing leaks
Commands
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>;
Fix now
ALTER SYSTEM KILL SESSION 'sid,serial#' IMMEDIATE; then fix application code to close cursors in all paths.
Cursor query is slow+
Immediate action
Get execution plan for the cursor's SELECT
Commands
EXPLAIN (ANALYZE, BUFFERS) SELECT ... (the cursor query)
Check if indexes are used; if not, add indexes on WHERE and ORDER BY columns.
Fix now
Rewrite using set-based SQL (JOIN, window function) and test performance improvement.
Memory usage growing per fetch+
Immediate action
Check if cursor is static (materializes entire result set)
Commands
In PostgreSQL: SELECT * FROM pg_prepared_statements WHERE statement LIKE '%DECLARE%';
Change cursor to `NO SCROLL` or use keyset cursor if possible.
Fix now
Convert to forward-only, read-only cursor to avoid temp table storage.
Database Cursor Types Comparison
Cursor TypeConsistency GuaranteeTemp Space UsageUpdate VisibilityBest Use Case
StaticSnapshot at open timeHigh (full result set)No changes visibleReporting, data export, small-mid result sets
KeysetViews updates to non-key columnsLow (only keys)No inserts/deletes from othersInteractive scrolling with partial updates
DynamicSees all changes (including inserts)None (live query)Full visibilityLive monitoring dashboards
Forward-only (read-only)None (no scroll, no updates)None (or temp for static)N/ADefault for most batch processing; fast & light

Key takeaways

1
Cursors are a legitimate tool for row-level stateful logic, but they are not a default
always start with set-based alternatives.
2
Choose the simplest cursor type
forward-only, read-only is fastest. Static only when you need a snapshot.
3
Bulk fetch with LIMIT is mandatory for any cursor processing more than a few hundred rows.
4
Close cursors explicitly in all code paths
leaked cursors are a common production outage cause.
5
Monitor open cursor count per session as part of your database health metrics.

Common mistakes to avoid

4 patterns
×

Forgetting to close the cursor after processing

Symptom
Open cursor count per session grows, eventually hitting the database's max open cursor limit (e.g., ORA-01000 in Oracle). Temp tables from static cursors not released, causing disk space to fill.
Fix
Wrap cursor usage in a try-catch-finally (or equivalent) block that ensures CLOSE is called. In PL/SQL, place CLOSE after the EXIT condition. In application code, use try-with-resources if the driver supports it.
×

Using a cursor when a set-based operation would suffice

Symptom
Stored procedure runs orders of magnitude slower than expected. Execution plan shows nested loop operations that could be replaced by a single JOIN or window function.
Fix
Analyze the business logic: if you are only aggregating, filtering, or updating based on a simple condition, rewrite using set-based SQL. Use recursive CTEs for running totals or graph traversal instead of cursors.
×

Updating the same table the cursor is reading from without proper isolation

Symptom
Deadlocks, 'Snapshot too old' errors (Oracle), or non-repeatable reads. In PostgreSQL, can lead to infinite loops if cursor re-fetches rows it just updated.
Fix
If you must update the same table, use 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

Symptom
Cursor loop takes hours for millions of rows; server CPU is high but throughput low. Each single-row fetch is a round trip even inside a stored procedure.
Fix
Add BULK COLLECT INTO ... LIMIT batch_size. Start with batch size 500 and tune based on row width and available PGA memory.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between a static cursor and a dynamic cursor. Whe...
Q02SENIOR
What is the impact of leaving a cursor open in a production database? Ho...
Q03SENIOR
You have a stored procedure that uses a cursor to update 50,000 rows one...
Q04SENIOR
What is a mutating table error and how does it relate to cursors?
Q01 of 04SENIOR

Explain the difference between a static cursor and a dynamic cursor. When would you use each?

ANSWER
A static cursor takes a snapshot of the result set when opened and stores it in a temporary table. It does not see any changes made by other transactions after open. Use static cursors for reporting or data exports where consistency matters and the dataset fits in temp space. A dynamic cursor reflects all data changes in real time — inserts, updates, deletes from other sessions are visible after each fetch. It uses no temp table but incurs higher lock contention. Use dynamic cursors for live monitoring dashboards or when you need to react to real-time changes. Key trade-off: static is consistent but expensive (temp space), dynamic is fresh but unpredictable (rows may disappear or appear mid-iteration).
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
When should I use a cursor instead of a set-based operation?
02
What's the difference between an implicit and explicit cursor in PL/SQL?
03
Does opening a cursor lock the underlying tables?
04
Can I use a cursor in a recursive CTE?
05
How do I monitor open cursors in PostgreSQL?
🔥

That's SQL Advanced. Mark it forged?

5 min read · try the examples if you haven't

Previous
Full-Text Search in SQL
11 / 16 · SQL Advanced
Next
Partitioning Tables in SQL