Mid-level 8 min · March 09, 2026

PL/SQL Cursors - ORA-01000 from Unclosed Exceptions

ORA-01000 crashing your midnight batch? Unclosed cursors in exception handlers leak.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Written from production experience, not tutorials.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • A cursor is a pointer to a private SQL work area (Context Area) holding the result of a SELECT
  • Implicit cursors are auto-created for DML and single-row SELECTs; explicit cursors give you full control over multi-row results
  • Cursor attributes (%FOUND, %NOTFOUND, %ISOPEN, %ROWCOUNT) are your only window into cursor state
  • A Cursor FOR Loop automatically manages open/fetch/close; your code can't leak resources even if an exception fires
  • Never use a cursor where a set-based UPDATE or MERGE can do the job — RBAR (Row By Agonizing Row) kills performance
✦ Definition~90s read
What is PL/SQL Cursors?

PL/SQL cursors are Oracle's mechanism for managing the context area—the private SQL workspace that holds parsed SQL statements, execution state, and fetched rows. Every SQL statement you execute in PL/SQL implicitly or explicitly creates a cursor. The problem arises when you open explicit cursors (via OPEN, FETCH, CLOSE) and fail to close them: each open cursor consumes a handle from the database's limited session-level resource, typically capped at 50 by default (OPEN_CURSORS parameter).

Think of PL/SQL Cursors Explained as a powerful tool in your developer toolkit.

When you hit that limit, Oracle raises ORA-01000: maximum open cursors exceeded, crashing your application with a connection-killing error. This is the single most common production issue with PL/SQL cursors, and it's entirely preventable.

Implicit cursors (those created by SELECT INTO or DML statements) are automatically managed—Oracle opens, fetches, and closes them for you. Explicit cursors give you fine-grained control: you declare them, open them, fetch rows one at a time, and must close them.

The cursor FOR loop is the modern best practice because it implicitly opens, fetches, and closes the cursor, eliminating the leak risk. Use explicit cursors only when you need to pass them as parameters, fetch across multiple scopes, or use bulk operations.

For everything else, the FOR loop is safer and cleaner.

Cursor attributes (%FOUND, %NOTFOUND, %ISOPEN, %ROWCOUNT) let you interrogate cursor state without touching the database again—critical for error handling and flow control. %ROWCOUNT tells you how many rows you've fetched so far, which is invaluable for logging progress in batch jobs. When you combine cursors with BULK COLLECT, you reduce context switches between PL/SQL and SQL engines by fetching hundreds or thousands of rows at once, often yielding 10x-100x performance improvements over row-by-row processing.

The trade-off is memory: bulk operations load entire result sets into collections, so you must manage collection size or use LIMIT clauses to avoid PGA exhaustion.

Plain-English First

Think of PL/SQL Cursors Explained as a powerful tool in your developer toolkit. Once you understand what it does and when to reach for it, everything clicks into place. Imagine you are a librarian with a massive stack of books. A standard SQL query is like asking for 'all history books' and having them all dumped on your desk at once. A cursor, however, is like a bookmark that allows you to point to one book at a time, read it, process it, and move to the next. It gives you a way to handle a large result set in a controlled, one-by-one fashion.

PL/SQL Cursors Explained is a fundamental concept in Database development. In Oracle, a cursor is a pointer to a private memory area (Context Area) that stores the result of a SELECT statement. While SQL is a set-based language, real-world business logic often requires iterative processing where each record must be evaluated individually before an action is taken.

In this guide, we'll break down exactly what PL/SQL Cursors Explained is, why it was designed this way to bridge the gap between set-based SQL and procedural logic, and how to use it correctly in real projects. We will explore the memory mechanics behind cursors and how to leverage cursor attributes to write defensive, production-grade code.

By the end, you'll have both the conceptual understanding and practical code examples to use PL/SQL Cursors Explained with confidence.

Why PL/SQL Cursors Leak Handles and Crash Your App

A PL/SQL cursor is a database handle that points to the result set of a query and tracks its iteration state. In Java, every time you call a stored procedure or execute a query that returns a result set via JDBC, the database allocates a private SQL area — a cursor — to manage that execution. The core mechanic: each cursor consumes a slot in the database's fixed-size cursor cache (often set to 300 by default). When you open a cursor but fail to close it in your Java code, that slot remains occupied until the JDBC connection is closed or the cursor is explicitly freed. This is not a memory leak on the JVM heap — it's a database-side resource leak that manifests as ORA-01000: maximum open cursors exceeded.

In practice, every ResultSet, CallableStatement, and PreparedStatement in JDBC implicitly opens a cursor on the Oracle server. The key property: Oracle does not automatically close cursors when the Java object goes out of scope or is garbage collected — the cursor lives until the connection is closed or you call close() on the statement/result set. Connection pools exacerbate this: if you return a connection to the pool without closing all its cursors, those cursors remain open and accumulate across pool checkouts. A typical production system with 50 connections and a default cursor limit of 300 can exhaust the pool in as few as 6 unclosed cursors per connection.

You must treat every cursor open as a paired close() in a finally block or try-with-resources. This is non-negotiable in any Java application that calls PL/SQL — batch jobs, REST APIs, or ETL pipelines. The real-world impact: your app will run fine in staging with low concurrency, then crash in production under load with ORA-01000, taking down the entire service until connections are recycled or the database is restarted.

GC ≠ Cursor Cleanup
Garbage collecting a Java Statement object does not close the underlying database cursor — only an explicit close() or closing the connection releases it.
Production Insight
A payment batch job that opened a cursor per invoice but only closed it on success (not on exception) left 200+ cursors open per connection after a partial network failure.
The symptom: after 3 minutes of retries, every new JDBC call threw ORA-01000, and the entire batch service became unresponsive until all connections in the pool were evicted.
Rule: always close cursors in a finally block or use try-with-resources — never rely on connection pool eviction or GC to clean up database handles.
Key Takeaway
Every JDBC Statement/ResultSet opens a database cursor — you must close it explicitly.
ORA-01000 is not a memory leak; it's a database handle leak that crashes your app under load.
Use try-with-resources for all JDBC objects — it guarantees close() even on exceptions.
PL/SQL Cursor Lifecycle and Leak Prevention THECODEFORGE.IO PL/SQL Cursor Lifecycle and Leak Prevention From implicit/explicit cursors to REF CURSOR and bulk collect Implicit vs Explicit Cursors Context area management; explicit needs OPEN/FETCH/CLOSE Cursor FOR Loop Auto-opens, fetches, closes; modern best practice Cursor Attributes %FOUND, %NOTFOUND, %ISOPEN, %ROWCOUNT Error Handling NO_DATA_FOUND and TOO_MANY_ROWS exceptions Bulk Collect FETCH BULK COLLECT INTO for performance REF CURSOR & FOR UPDATE Cursor variable for passing; lock rows with FOR UPDATE ⚠ Unclosed explicit cursors cause ORA-01000 Always CLOSE cursors; use FOR loop to auto-close THECODEFORGE.IO
thecodeforge.io
PL/SQL Cursor Lifecycle and Leak Prevention
Plsql Cursors

The Context Area: Implicit vs. Explicit Cursors

PL/SQL Cursors Explained is a core feature of PL/SQL. It was designed to solve a specific problem: SQL is naturally set-based, but procedural languages often need to manipulate individual rows. Cursors act as the bridge.

There are two primary types: 1. Implicit Cursors: Automatically created by Oracle whenever you execute a DML statement (INSERT, UPDATE, DELETE) or a SELECT INTO. You access their metadata using the SQL% prefix. 2. Explicit Cursors: Defined by the developer in the DECLARE section for queries that return multiple rows.

They exist to give you granular control over the context area, allowing you to track how many rows were affected (%ROWCOUNT), if a row was found (%FOUND), or if the cursor is still open (%ISOPEN). Managing these properly is the difference between a high-performance application and one that suffers from memory leaks.

io/thecodeforge/plsql/ExplicitCursor.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
25
-- io.thecodeforge: Standard Explicit Cursor Implementation
DECLARE
    -- 1. Declaration
    CURSOR c_forge_projects IS
        SELECT name, status FROM forge_projects WHERE active = 'Y';
    
    v_name   forge_projects.name%TYPE;
    v_status forge_projects.status%TYPE;
BEGIN
    -- 2. Opening the cursor
    OPEN c_forge_projects;
    
    LOOP
        -- 3. Fetching data into variables
        FETCH c_forge_projects INTO v_name, v_status;
        
        -- 4. Exit condition using cursor attributes
        EXIT WHEN c_forge_projects%NOTFOUND;
        
        DBMS_OUTPUT.PUT_LINE('Project: ' || v_name || ' | Status: ' || v_status);
    END LOOP;
    
    -- 5. Closing the cursor to free memory
    CLOSE c_forge_projects;
END;
Output
Project: ForgeAPI | Status: ACTIVE
Project: CodeGuard | Status: STABLE
Key Insight:
The most important thing to understand about PL/SQL Cursors Explained is the problem it was designed to solve. Always ask 'why does this exist?' before asking 'how do I use it?' Use cursors when you need to perform complex logic on each row that cannot be expressed in a single SQL statement.
Production Insight
Implicit cursors are managed entirely by Oracle — you can't leak them.
Explicit cursors require manual OPEN, FETCH, CLOSE — one missing CLOSE and you're leaking.
Rule: if you need explicit control, use a Cursor FOR Loop; it auto-closes even on exceptions.
Key Takeaway
Implicit cursors = safe, low-control.
Explicit cursors = full control, full responsibility.
The Cursor FOR Loop gives you control without the leak risk.

The Cursor FOR Loop: Modern Best Practices

When learning PL/SQL Cursors Explained, most developers hit the same set of gotchas. A critical mistake is forgetting to CLOSE an explicit cursor, leading to 'Maximum Open Cursors Exceeded' (ORA-01000) errors. Another is checking %NOTFOUND before the first FETCH, which yields unreliable results.

In modern PL/SQL, the Cursor FOR Loop is the gold standard. It implicitly handles the entire lifecycle: it opens the cursor, fetches rows into a record variable, and closes the cursor automatically even if an exception occurs. This 'managed' approach significantly reduces the surface area for bugs and resource leaks, though it still operates on a row-by-row basis (RBAR).

io/thecodeforge/plsql/ModernCursorLoop.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- io.thecodeforge: The modern, cleaner Cursor FOR Loop approach
-- This is the production-grade way to handle multi-row results
DECLARE
    v_processed_count NUMBER := 0;
BEGIN
    -- Managed cursor: No need for explicit OPEN, FETCH, or CLOSE
    FOR r_project IN (SELECT name, status FROM forge_projects WHERE active = 'Y') LOOP
        DBMS_OUTPUT.PUT_LINE('Processing: ' || r_project.name);
        
        -- Complex business logic here
        v_processed_count := v_processed_count + 1;
    END LOOP;
    
    -- Accessing implicit cursor attribute for the last DML
    DBMS_OUTPUT.PUT_LINE('Total Processed: ' || v_processed_count);
END;
Output
Processing: ForgeAPI
Processing: CodeGuard
Total Processed: 2
Watch Out:
The most common mistake with PL/SQL Cursors Explained is using it when a simpler alternative would work better. Row-by-row processing (RBAR) is significantly slower than set-based SQL. Only use cursors if you absolutely cannot perform the task with a standard UPDATE or INSERT statement.
Production Insight
A Cursor FOR Loop automatically closes the cursor on COMMIT, ROLLBACK, or exception — you can't leak it.
But it still fetches one row at a time — for large result sets, the network round-trips add up.
Rule: use Cursor FOR Loop for clean code; use BULK COLLECT when performance matters (see section below).
Key Takeaway
Cursor FOR Loop = managed resource, safer code.
Still row-by-row — not a performance silver bullet.
Prefer it over explicit OPEN/FETCH/CLOSE for maintainability.

Cursor Attributes in Depth — %FOUND, %NOTFOUND, %ISOPEN, %ROWCOUNT

Cursor attributes are your only window into the state of the cursor. For implicit cursors, use the SQL% prefix. For explicit cursors, use cursor_name%. Here's what each does and when it's safe to call:

  • %FOUND: Returns TRUE if the most recent FETCH returned a row. For implicit cursors, returns TRUE if the DML affected at least one row.
  • %NOTFOUND: Opposite of %FOUND. Critical for loop exit conditions.
  • %ISOPEN: Returns TRUE if the cursor is open. Check this before opening to avoid "cursor already open" error.
  • %ROWCOUNT: Number of rows fetched so far. For implicit cursors, number of rows affected by the DML.

A common trap: calling %NOTFOUND immediately after OPEN but before any FETCH returns NULL, not TRUE. That's why you must FETCH before checking — except in a Cursor FOR Loop where Oracle does it for you.

io/thecodeforge/plsql/CursorAttributes.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- io.thecodeforge: Demonstrating cursor attributes safely
DECLARE
    CURSOR c_emp IS SELECT employee_id FROM employees WHERE department_id = 50;
    v_eid employees.employee_id%TYPE;
BEGIN
    IF NOT c_emp%ISOPEN THEN
        OPEN c_emp;
    END IF;
    
    LOOP
        FETCH c_emp INTO v_eid;
        EXIT WHEN c_emp%NOTFOUND;  -- only safe after first FETCH
        DBMS_OUTPUT.PUT_LINE('Fetched: ' || v_eid || ' (row ' || c_emp%ROWCOUNT || ')');
    END LOOP;
    
    CLOSE c_emp;
    
    -- Implicit cursor after DML
    UPDATE employees SET salary = salary * 1.1 WHERE department_id = 100;
    DBMS_OUTPUT.PUT_LINE('Updated rows: ' || SQL%ROWCOUNT);
END;
Output
Fetched: 201 (row 1)
Fetched: 202 (row 2)
Updated rows: 3
Gotcha:
%NOTFOUND after OPEN but before any FETCH = NULL, not TRUE. Never rely on it to exit before the first FETCH. The Cursor FOR Loop handles this correctly internally.
Production Insight
%ISOPEN is your best defense against ORA-06511 (cursor already open) — always check before OPEN.
%ROWCOUNT for implicit cursors gives you exact DML impact. Use it to log audit trails.
Rule: always validate cursor state with attributes before acting.
Key Takeaway
%FOUND/%NOTFOUND safe only after FETCH.
%ISOPEN prevents double-open errors.
%ROWCOUNT is your audit log helper.

Error Handling with Cursors — NO_DATA_FOUND and TOO_MANY_ROWS

Implicit cursors (SELECT INTO) raise exceptions when the query returns zero rows (NO_DATA_FOUND) or more than one row (TOO_MANY_ROWS). Explicit cursors handle these cases gracefully: no rows simply means no FETCH and %NOTFOUND becomes TRUE. The mismatch catches many developers off guard.

If you need to use SELECT INTO with a possibility of zero rows, wrap it in a BEGIN EXCEPTION block. Alternatively, use a cursor FOR loop that does nothing when no rows match — no exception, no special handling.

Explicit cursors with FOR UPDATE can raise deadlock (ORA-00060) if rows are locked by another session. Always use NOWAIT or WAIT n to control lock behaviour.

io/thecodeforge/plsql/CursorExceptionHandling.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- io.thecodeforge: Handling cursor-related exceptions
DECLARE
    CURSOR c_emp IS SELECT salary FROM employees WHERE employee_id = 99999;
    v_sal employees.salary%TYPE;
BEGIN
    -- Option 1: Use cursor for loop (no exception for no rows)
    FOR r IN c_emp LOOP
        DBMS_OUTPUT.PUT_LINE('Salary: ' || r.salary);
    END LOOP;
    
    -- Option 2: Select INTO with exception handling
    BEGIN
        SELECT salary INTO v_sal FROM employees WHERE employee_id = 99999;
    EXCEPTION
        WHEN NO_DATA_FOUND THEN
            v_sal := 0;
        WHEN TOO_MANY_ROWS THEN
            v_sal := NULL;
    END;
    
    DBMS_OUTPUT.PUT_LINE('Salary after exception handling: ' || NVL(v_sal, -1));
END;
Output
Salary after exception handling: -1
Rule of Thumb:
If the query might return zero rows, use a cursor FOR loop instead of SELECT INTO. It eliminates NO_DATA_FOUND handling entirely.
Production Insight
NO_DATA_FOUND from a SELECT INTO inside a loop will abort the entire procedure — not just that row.
Wrapping with BEGIN EXCEPTION is mandatory if you can't guarantee at least one row.
Rule: cursor FOR loop is exception-safe; SELECT INTO is not.
Key Takeaway
SELECT INTO raises exceptions on zero/multiple rows.
Cursor FOR loop handles empty results gracefully.
Use FOR UPDATE NOWAIT to avoid deadlocks.

Bulk Collect with Cursors — When Row-by-Row Is Too Slow

RBAR (Row By Agonizing Row) is the enemy of performance. Every FETCH incurs a network round-trip between PL/SQL and SQL engine. For large result sets (thousands of rows), the overhead becomes unacceptable. BULK COLLECT fetches rows in batches, typically 100 at a time (or using LIMIT).

Use BULK COLLECT when you need to process a result set procedurally but cannot use set-based SQL. It reduces context switches dramatically. Combine it with FORALL for DML operations to further boost performance.

Trade-off: memory consumption. If you BULK COLLECT without a LIMIT clause, you might pull the entire result set into memory, causing ORA-04036 (PGA memory exhausted). Always use LIMIT.

io/thecodeforge/plsql/BulkCollectCursor.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- io.thecodeforge: Using BULK COLLECT with LIMIT for safe batch processing
DECLARE
    CURSOR c_emp IS SELECT employee_id, salary FROM employees WHERE department_id = 50;
    TYPE t_emp_tab IS TABLE OF c_emp%ROWTYPE;
    v_emps t_emp_tab;
    v_limit CONSTANT POSITIVE := 100;
BEGIN
    OPEN c_emp;
    LOOP
        FETCH c_emp BULK COLLECT INTO v_emps LIMIT v_limit;
        EXIT WHEN v_emps.COUNT = 0;
        
        FOR i IN 1..v_emps.COUNT LOOP
            -- Process each row individually
            DBMS_OUTPUT.PUT_LINE('Processing: ' || v_emps(i).employee_id);
        END LOOP;
    END LOOP;
    CLOSE c_emp;
END;
Output
Processing: 201
Processing: 202
...
Performance Mental Model:
  • Standard cursor loop: 1 trip per row → high cost for large sets
  • BULK COLLECT without LIMIT: 1 trip for all rows → memory risk
  • BULK COLLECT with LIMIT: controlled batch size → best of both
Production Insight
Without LIMIT, a 1 million row BULK COLLECT can consume 500+ MB of PGA — your session crashes.
With LIMIT 100, you process 100 rows at a time; memory stays flat.
Rule: always add LIMIT; test with realistic row counts in staging.
Key Takeaway
BULK COLLECT reduces context switches.
LIMIT clause prevents memory blowup.
Use for large result sets that cannot be set-based.

Cursor Variable (REF CURSOR) — The Only Way to Pass Result Sets Around

A normal explicit cursor is a fixed query, hardcoded at compile time. That's fine for simple stuff, but the second you need to pass a result set to another procedure, return a dynamic query from a function, or build a cursor based on runtime parameters, you're stuck. That's where REF CURSOR comes in. It's a pointer to a result set, not the query itself. You can pass it around like a hot potato. Weak REF CURSORs let you shape the query on the fly — strong ones enforce a fixed return structure at compile time. In production, you'll mostly see SYS_REFCURSOR as the return type of functions that build dynamic reports or filter logic by user roles. Why this matters: static cursors force you into copy-paste hell when you need the same loop logic against different tables or conditions. REF CURSOR gives you one loop, one handler, and a parameterized query. Saves you from the code rot that kills maintainability.

RefCursorDynamicReport.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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// io.thecodeforge — database tutorial

CREATE OR REPLACE FUNCTION get_orders_by_status(
    p_status IN VARCHAR2
) RETURN SYS_REFCURSOR
IS
    v_cursor SYS_REFCURSOR;
BEGIN
    -- Build the query dynamically — status isn't known until runtime
    OPEN v_cursor FOR
        'SELECT order_id, customer_name, order_date, total_amount
         FROM orders
         WHERE status = :status'
        USING p_status;

    -- Return the pointer, caller is responsible for fetching and closing
    RETURN v_cursor;
END get_orders_by_status;
/

-- Caller fetches the result set
DECLARE
    v_order_id     orders.order_id%TYPE;
    v_customer     orders.customer_name%TYPE;
    v_order_date   orders.order_date%TYPE;
    v_total        orders.total_amount%TYPE;
    v_cursor       SYS_REFCURSOR;
BEGIN
    v_cursor := get_orders_by_status('SHIPPED');

    LOOP
        FETCH v_cursor INTO v_order_id, v_customer, v_order_date, v_total;
        EXIT WHEN v_cursor%NOTFOUND;
        DBMS_OUTPUT.PUT_LINE(v_order_id || ' | ' || v_customer || ' | ' || v_total);
    END LOOP;

    CLOSE v_cursor;   -- You open it, you close it. No exceptions.
END;
/
Output
1024 | Acme Corp | 4500.75
2048 | Beta Inc | 1200.00
3072 | Gamma Ltd | 8900.50
Production Trap: Unclosed REF CURSORs are memory leaks
Every OPEN v_cursor FOR must have a matching CLOSE v_cursor. Forgetting it pins the cursor in the shared pool until the session dies. In high-concurrency apps, this crashes the instance. Wrap REF CURSOR usage in try-catch-finally or use a wrapper procedure that guarantees close.
Key Takeaway
Use REF CURSOR when the query shape or result set must live beyond a single procedure. Always pair OPEN with CLOSE.

Cursor FOR UPDATE — When You Need to Lock Rows and Modify Them Without Corruption

You're looping through orders, applying discounts, and updating totals. Without a lock, two sessions reading the same row will overwrite each other's work. That's a corrupt database with a side of angry customers. CURSOR FOR UPDATE solves this by acquiring row-level locks as you fetch. The database won't let anyone else modify those rows until you commit or roll back. The default wait behavior is infinite — that's a deadlock invitation. In production, you always set WAIT N (e.g., WAIT 5) so your process gives up after N seconds instead of hanging the whole app. Pair this with WHERE CURRENT OF to update the row without re-querying — you reference the cursor's current position directly, which is faster and safer than re-specifying the WHERE clause. Why this matters: row-by-row updates without locking are a race condition disaster. With FOR UPDATE and WHERE CURRENT OF, you get atomic read-modify-write. No lost updates. No phantom locks hanging around forever.

ForUpdateWithTimeout.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — database tutorial

DECLARE
    -- Lock rows for 10 seconds max, raise error if lock can't be acquired
    CURSOR c_pending_orders IS
        SELECT order_id, total_amount
        FROM orders
        WHERE status = 'PENDING'
        FOR UPDATE OF status WAIT 10;
BEGIN
    FOR rec IN c_pending_orders LOOP
        -- Apply 5% loyalty discount
        UPDATE orders
        SET total_amount = rec.total_amount * 0.95,
            status = 'PROCESSED'
        WHERE CURRENT OF c_pending_orders;  -- Directly targets the locked row

        DBMS_OUTPUT.PUT_LINE('Order ' || rec.order_id || ' discounted to ' || (rec.total_amount * 0.95));
    END LOOP;

    COMMIT;  -- Release all locks atomically
END;
/
Output
Order 1001 discounted to 2850.00
Order 1002 discounted to 1425.00
Order 1003 discounted to 4750.00
Senior Shortcut: WAIT N is mandatory in production
Omitting WAIT means the session blocks forever if another session holds the lock. Use WAIT 5 or WAIT 10. If you get ORA-00054, catch it, log it, and retry. Never risk an app-wide hang over a row lock.
Key Takeaway
Always specifiy WAIT N in FOR UPDATE cursors. Use WHERE CURRENT OF for updates to avoid double-index lookups and race conditions.

Explicit Cursor Lifecycle: Open, Fetch, Close — Or Else

Every explicit cursor you write goes through three stages: OPEN, FETCH, and CLOSE. Miss any one, and you leak memory in the PGA. Oracle allocates a private SQL area when you OPEN. If you never CLOSE, that handle stays allocated until the session dies. In a production OLTP system with hundreds of concurrent sessions, this is how you kill the database's shared pool.

Why do you need explicit cursors at all? When you need multiple result sets, or when you must fetch rows in batches with control over each step. But here's the rule: never OPEN without a corresponding CLOSE in a structured exception block. Use a pattern where you OPEN, loop FETCH into variables until %NOTFOUND, then CLOSE. Wrap the FETCH in an EXCEPTION section that CLOSEs on error. If you forget, your DBA will find you.

The HOW is simple. DECLARE the cursor with SELECT, OPEN it, FETCH into variables, check %FOUND or %NOTFOUND, CLOSE. That's it. Don't overthink it. Just respect the lifecycle.

explicit_cursor_lifecycle.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
// io.thecodeforge — database tutorial

DECLARE
   CURSOR emp_cur IS
      SELECT employee_id, last_name
      FROM employees
      WHERE department_id = 50;
   v_id   employees.employee_id%TYPE;
   v_name employees.last_name%TYPE;
BEGIN
   OPEN emp_cur;
   LOOP
      FETCH emp_cur INTO v_id, v_name;
      EXIT WHEN emp_cur%NOTFOUND;
      DBMS_OUTPUT.PUT_LINE(v_id || ': ' || v_name);
   END LOOP;
   CLOSE emp_cur;  -- mandatory!
EXCEPTION
   WHEN OTHERS THEN
      IF emp_cur%ISOPEN THEN
         CLOSE emp_cur;  -- close on error too
      END IF;
      RAISE;
END;
Output
107: Matos
123: Vargas
147: Lorentz
Production Trap:
If you OPEN a cursor in a loop and forget CLOSE, each iteration leaks one PGA handle. A 10,000-iteration batch will crash the session with ORA-01000. Always pair OPEN with CLOSE, and always in the exception block.
Key Takeaway
Every OPEN must have a matching CLOSE in both normal and exception paths.

Cursor Parameters: Stop Hard-Coding Where Clauses

Hard-coding WHERE clauses in cursor definitions is bad practice. You create a new cursor for every variation: CURSOR emp_dept_10, CURSOR emp_dept_20, ad infinitum. That's copy-paste garbage. Instead, parameterize your cursor like a function. Declare parameters in parentheses after the cursor name, then reference them inside the SELECT.

Why? Because one parameterized cursor replaces ten hard-coded ones. You call it with different values at OPEN time. The execution plan is cached and reused for each bind value, saving parsing overhead. This is how you write maintainable code that doesn't make your code reviewer cry.

Syntax is clean: CURSOR cursor_name (param1 datatype, param2 datatype) IS SELECT ... WHERE column = param1. Then OPEN cursor_name(value1, value2). You can also use default values for optional parameters. Just remember: the datatype must match the column, not some arbitrary variable type. Use %TYPE when possible to stay in sync with the table definition.

parameterized_cursor.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
25
26
27
28
// io.thecodeforge — database tutorial

DECLARE
   CURSOR emp_by_dept (p_dept_id employees.department_id%TYPE) IS
      SELECT employee_id, last_name
      FROM employees
      WHERE department_id = p_dept_id
      ORDER BY last_name;
   v_id   employees.employee_id%TYPE;
   v_name employees.last_name%TYPE;
BEGIN
   -- Same cursor, two departments
   OPEN emp_by_dept(50);
   LOOP
      FETCH emp_by_dept INTO v_id, v_name;
      EXIT WHEN emp_by_dept%NOTFOUND;
      DBMS_OUTPUT.PUT_LINE('Dept 50: ' || v_name);
   END LOOP;
   CLOSE emp_by_dept;
   
   OPEN emp_by_dept(60);
   LOOP
      FETCH emp_by_dept INTO v_id, v_name;
      EXIT WHEN emp_by_dept%NOTFOUND;
      DBMS_OUTPUT.PUT_LINE('Dept 60: ' || v_name);
   END LOOP;
   CLOSE emp_by_dept;
END;
Output
Dept 50: Lorentz
Dept 50: Matos
Dept 50: Vargas
Dept 60: Austin
Dept 60: Pataballa
Senior Shortcut:
Use parameters with default values so the cursor works with an optional filter. Example: CURSOR c (p_id NUMBER DEFAULT NULL) IS SELECT * FROM emp WHERE dept_id = NVL(p_id, dept_id). Now calling OPEN c works for all rows, OPEN c(50) filters.
Key Takeaway
Parameterized cursors replace dozens of hard-coded cursor definitions and share execution plans.
● Production incidentPOST-MORTEMseverity: high

ORA-01000: The Midnight Batch That Broke the Night Shift

Symptom
After running successfully for 30-40 minutes, the batch job would crash with ORA-01000. Restarting helped briefly, but the error returned within 10 minutes.
Assumption
The team assumed the database parameter OPEN_CURSORS was too low and increased it from 300 to 1000. The problem persisted.
Root cause
An explicit cursor within an exception handler was opened but never closed when an error occurred before the FETCH loop. Each failure leaked one cursor; within hours, the session hit the limit.
Fix
Wrapped all explicit cursors in a Cursor FOR Loop, or added a CLOSE statement inside the exception handler's WHEN OTHERS block. Also added %ISOPEN checks before opening.
Key lesson
  • Explicit cursors (OPEN without managed loop) are resource leaks waiting to happen.
  • Always close cursors in exception handlers, not just in the normal flow.
  • ORA-01000 isn't always an OPEN_CURSORS limit problem — it's often a leak.
  • Use Cursor FOR Loops in all new code; they're the only safe default.
Production debug guideSymptom → Action guide for the most common cursor failures4 entries
Symptom · 01
ORA-01000: maximum open cursors exceeded
Fix
Query v$open_cursor for the session: SELECT * FROM v$open_cursor WHERE sid = <your_sid>; Then inspect the SQL_TEXT column for repeated statements. Look for cursors opened without a corresponding CLOSE.
Symptom · 02
Cursor returns no rows when data exists
Fix
Check if you're testing %NOTFOUND before the first FETCH. If so, restructure the loop: FETCH first, then EXIT WHEN %NOTFOUND. For Cursor FOR Loops, the engine handles this correctly.
Symptom · 03
UPDATE via cursor fails with ORA-00060 (deadlock)
Fix
Use FOR UPDATE NOWAIT in the cursor declaration to lock rows upfront, or use a single UPDATE statement instead. Check v$lock and v$session for blocking sessions.
Symptom · 04
SELECT INTO raises NO_DATA_FOUND unexpectedly
Fix
Wrap the statement in BEGIN EXCEPTION WHEN NO_DATA_FOUND THEN ... END; Or use a cursor FOR loop that naturally handles zero rows without exception.
★ Quick Cursor Debug Cheat SheetRun these commands when you suspect cursor problems in production
ORA-01000: max open cursors
Immediate action
Find the session and its open cursors
Commands
SELECT * FROM v$open_cursor WHERE sid = <sid>;
SELECT a.value, s.username FROM v$sesstat a, v$statname b, v$session s WHERE a.statistic# = b.statistic# AND b.name = 'opened cursors current' AND a.sid = s.sid;
Fix now
Kill the offending session or temporarily increase OPEN_CURSORS: ALTER SYSTEM SET open_cursors=500 SCOPE=BOTH; But then fix the leak.
Cursor loop never exits+
Immediate action
Check if the exit condition is missing or wrong
Commands
SELECT sql_fulltext FROM v$sql WHERE sql_id = '<cursor_sql_id>';
Enable tracing: EXEC DBMS_MONITOR.session_trace_enable(session_id => <sid>);
Fix now
Add EXIT WHEN cursor%NOTFOUND; or switch to a Cursor FOR Loop.
Implicit vs. Explicit Cursor Comparison
FeatureImplicit CursorExplicit Cursor
DeclarationAutomatic (SQL% prefix)Manual (DECLARE section)
ManagementManaged by Oracle EngineManaged by Developer
Use CaseDML and single-row SELECT INTOMulti-row procedural processing
ControlMinimal (attribute check only)Full control over FETCH logic
AttributesSQL%FOUND, SQL%ROWCOUNT, etc.cursor_name%FOUND, %NOTFOUND, etc.
Exception RiskNO_DATA_FOUND / TOO_MANY_ROWS on SELECT INTOLeaks if not closed; ORA-06511 if double open
Performance PatternBest for single-row DMLRow-by-row; use BULK COLLECT for speed

Key takeaways

1
Implicit cursors are safe for DML; explicit cursors require careful lifecycle management.
2
Cursor FOR Loops prevent resource leaks
use them as your default for multi-row processing.
3
Cursor attributes (%FOUND, %NOTFOUND, %ROWCOUNT) are critical for defensive coding, but mind their timing.
4
BULK COLLECT with LIMIT is the performance fix for large result sets
never BULK without LIMIT.
5
Set-based SQL always wins on performance; reach for cursors only when procedural logic is unavoidable.
6
Handle NO_DATA_FOUND / TOO_MANY_ROWS with exception blocks or switch to cursor FOR loops.

Common mistakes to avoid

4 patterns
×

Overusing Cursors when set-based SQL would work

Symptom
Batch jobs run slowly, consuming high CPU and I/O. A single UPDATE statement would have completed in seconds.
Fix
Rewrite the logic as a single UPDATE, MERGE, or INSERT-SELECT. Only use cursors for procedural operations (calling APIs per row, conditional logic beyond SQL's capacity).
×

Forgetting to close explicit cursors in exception handlers

Symptom
Intermittent ORA-01000 errors after a failure, often seen in long-running sessions. The error appears only after the exception path executes.
Fix
Use Cursor FOR loops (auto-close) or add a CLOSE inside WHEN OTHERS. Also protect with %ISOPEN checks.
×

Checking %NOTFOUND before the first FETCH

Symptom
The loop exits immediately without processing any rows, even though data exists. The cursor attribute returns NULL, which is not TRUE, but the logic treats it as exit condition.
Fix
Restructure the loop: FETCH first, then EXIT WHEN %NOTFOUND. Cursor FOR loops handle this correctly.
×

Not handling NO_DATA_FOUND in SELECT INTO within a loop

Symptom
A loop that performs SELECT INTO for each iteration crashes abruptly on the first row that doesn't match. The entire batch fails, not just that row.
Fix
Wrap the SELECT INTO in a BEGIN EXCEPTION block, or switch to a cursor FOR loop that returns the needed columns.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between an implicit and an explicit cursor in Ora...
Q02SENIOR
Explain the four main cursor attributes (%FOUND, %NOTFOUND, %ISOPEN, %RO...
Q03JUNIOR
Why is a Cursor FOR Loop generally preferred over a Basic Loop with expl...
Q04SENIOR
What is the 'Maximum Open Cursors' error (ORA-01000) and what are the pr...
Q05JUNIOR
How do you handle the case where a SELECT INTO statement returns more th...
Q06SENIOR
What is the 'FOR UPDATE' clause in a cursor declaration, and how does it...
Q01 of 06JUNIOR

What is the difference between an implicit and an explicit cursor in Oracle PL/SQL? When would you use each?

ANSWER
An implicit cursor is automatically created by Oracle for DML (INSERT, UPDATE, DELETE) and single-row SELECT INTO statements. You access its attributes via the SQL% prefix. An explicit cursor is declared by the developer in the DECLARE section for multi-row queries. Use implicit cursors for simple DML and guaranteed single-row queries. Use explicit cursors (preferably as Cursor FOR Loops) when you need to process multiple rows procedurally.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I use a cursor inside a function?
02
What's the difference between a cursor and a REF CURSOR?
03
How many cursors can I have open at once?
04
Is a Cursor FOR Loop slower than an explicit FETCH loop?
05
Can I use BULK COLLECT with a Cursor FOR Loop?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Written from production experience, not tutorials.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's PL/SQL. Mark it forged?

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

Previous
PL/SQL Control Structures — IF, LOOP, WHILE
4 / 27 · PL/SQL
Next
PL/SQL Stored Procedures and Functions