Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

PL/SQL & T-SQL (Oracle and SQL Server)  Module 10 – Cursors, collections and bulk processing

Explicit cursors and cursor FOR loops

Declare, open, fetch from and close an explicit cursor on the practice ledger, read its four attributes, choose a FOR loop and lock with FOR UPDATE.

  • Intermediate
  • 15 minutes
  • Examples run with oracle-free 23.26.3.0.0, mssql 17.0.5005.3, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Declare, open and read rows from an explicit cursor with parameters
  • Use %FOUND, %NOTFOUND, %ROWCOUNT and %ISOPEN to control a cursor loop
  • Choose a cursor FOR loop and count its fetch calls at each optimize level
  • Update fetched rows with FOR UPDATE and WHERE CURRENT OF safely

Before you start

What you’ll learn · Before you start

What you will learn

  • Declare, open and read rows from an explicit cursor with parameters
  • Use %FOUND, %NOTFOUND, %ROWCOUNT and %ISOPEN to control a cursor loop
  • Choose a cursor FOR loop and count its fetch calls at each optimize level
  • Update fetched rows with FOR UPDATE and WHERE CURRENT OF safely

Before you start

On this page

Account 2 of the practice ledger holds the current account of Asha Rao; by the end of the quarter it has nine entries, and the bank’s statement job prints them in order with a running balance. One SQL query with a window function computes every balance at once, as the matching T-SQL lesson of this track teaches, but a PL/SQL job that must also log each line, round odd paise or call another procedure needs the rows one at a time.

This module teaches four shapes for that work, and this lesson the first three. You will walk the life of an explicit cursor over the practice ledger, read its four attributes, choose between an explicit loop and a cursor FOR loop, and lock the rows you plan to change with FOR UPDATE so no other session touches them in between.

An explicit cursor steps through the result of a query

Oracle’s cursors overview distinguishes two kinds of session cursor. PL/SQL opens an implicit cursor around every SELECT INTO or DML statement, and the lesson on SQL inside PL/SQL read its attributes, such as SQL%ROWCOUNT. An explicit cursor: A named session cursor you declare, open, fetch from and close yourself in a PL/SQL block, with four attributes you can read; an implicit cursor, by contrast, is opened by PL/SQL around a SELECT INTO or DML statement. is one you name, declare and manage yourself; OPEN runs its query, FETCH ... INTO reads one row into variables, and CLOSE releases its resources. The shape of the loop is fixed:

Interactive · message by message One explicit cursor, from OPEN to CLOSE Walk the four moves of account_cursor.sql and read the four attributes after each one.
One explicit cursor, from OPEN to CLOSE, step 7 of 7. Messages so far: PL/SQL block to SQL engine: account_id = 2; SQL engine to PL/SQL block: 1 row: 18000.00; SQL engine to PL/SQL block: 1 row: 800.00; PL/SQL block to SQL engine: close.PL/SQL blockclosed: INVALID_CURSORSQL engineruns the query of the cursoraccount_id = 21 row: 18000.001 row: 800.00close

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Before OPEN the cursor is closed. %ISOPEN is FALSE, and %FOUND, %NOTFOUND and %ROWCOUNT raise INVALID_CURSOR, so the block cannot read them yet.
  2. OPEN runs the query. OPEN c_ledger(2) binds p_account to 2 and sends the query to the SQL engine. The result set is ready, but no row has been read.
  3. After OPEN the counters reset. %ISOPEN is TRUE, %FOUND and %NOTFOUND are NULL and %ROWCOUNT is 0: the cursor is ready, but a test such as EXIT WHEN c%NOTFOUND would read NULL.
  4. The first FETCH reads one row. FETCH c_ledger INTO v_entry, v_amount, v_memo returns entry 2 of the ledger, the opening deposit of 18000.00. %FOUND becomes TRUE, %NOTFOUND FALSE and %ROWCOUNT 1.
  5. The ninth FETCH reads row 9. After eight more passes %ROWCOUNT reads 9. The ninth row is the last entry the script inserted, 800.00 on account 2, and the next FETCH goes past the end of the result.
  6. One more FETCH returns no row. %FOUND becomes FALSE, %NOTFOUND becomes TRUE and %ROWCOUNT stays at 9; EXIT WHEN c_ledger%NOTFOUND leaves the loop at this point.
  7. CLOSE releases the cursor. CLOSE c_ledger ends the cursor, so %ISOPEN is FALSE again and the other attributes once more raise INVALID_CURSOR; a FETCH from here raises ORA-01001.

Account 2 of the practice schema starts with one ledger row, so account_cursor.sql inserts eight more before it opens the cursor; the loop then fetches nine rows in order and reports the attribute values after each one:

account_cursor.sql: nine rows of one account, through one cursor SQL · cursor/account_cursor.sql
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- An explicit cursor over account 2 of the practice ledger, with a parameter and a default value.
-- Runs on the practice schema (set up by schema_oracle.sql of practice-schema-and-dialect-basics first). The script
-- inserts eight more ledger rows for account 2 so there are nine to fetch, opens a cursor with its default parameter,
-- loops with FETCH ... INTO and prints %ROWCOUNT, %FOUND and %NOTFOUND after each pass. ROLLBACK at the end leaves
-- the data as it was. cursor_model.py in examples/model prints the same attribute values from the documented rules.
SET SERVEROUTPUT ON
SET LINESIZE 120

-- Add eight more ledger entries for account 2 (entry_id and ledger_entries_seq are GENERATED ALWAYS).
INSERT INTO ledger (account_id, amount, posted_at, memo)
SELECT 2,
       CASE MOD(LEVEL, 2) WHEN 0 THEN LEVEL * 100 ELSE -LEVEL * 50 END,
       TIMESTAMP '2026-09-01 09:00:00' + NUMTODSINTERVAL(LEVEL, 'DAY'),
       'extra ledger line ' || LEVEL
FROM dual CONNECT BY LEVEL <= 8;

DECLARE
  CURSOR c_ledger (p_account INT DEFAULT 2) IS
    SELECT entry_id, amount, memo
    FROM ledger
    WHERE account_id = p_account
    ORDER BY posted_at, entry_id;
  v_entry  ledger.entry_id%TYPE;
  v_amount ledger.amount%TYPE;
  v_memo   ledger.memo%TYPE;
BEGIN
  OPEN c_ledger;                                                 -- the default binds p_account to 2
  LOOP
    FETCH c_ledger INTO v_entry, v_amount, v_memo;
    EXIT WHEN c_ledger%NOTFOUND;                                 -- NOTFOUND is TRUE only after the last FETCH
    DBMS_OUTPUT.PUT_LINE(
      'count=' || c_ledger%ROWCOUNT ||
      ' found=' || CASE WHEN c_ledger%FOUND THEN 'TRUE' ELSE 'FALSE' END ||
      ' notfound=' || CASE WHEN c_ledger%NOTFOUND THEN 'TRUE' ELSE 'FALSE' END ||
      ' entry=' || v_entry ||
      ' amount=' || v_amount
    );
  END LOOP;
  DBMS_OUTPUT.PUT_LINE('rows fetched: ' || c_ledger%ROWCOUNT);   -- 9 after the loop ends
  CLOSE c_ledger;
END;
/
-- Expected: nine lines report count 1 through 9 with found=TRUE and notfound=FALSE, then "rows fetched: 9".

-- removes the eight extra rows this script inserted
ROLLBACK;
account_cursor.sql, run in the lab Python · account_cursor_session.py
"""account_cursor.sql in the lab: SQL*Plus runs it as LAB on a freshly loaded practice schema."""

from oracle_session import Lab

with Lab() as lab:
    lab.sqlplus("cursor/account_cursor.sql")

Output

$ sqlplus -s lab@//localhost/FREEPDB1 @cursor/account_cursor.sql
8 rows created.

count=1 found=TRUE notfound=FALSE entry=2 amount=18000
count=2 found=TRUE notfound=FALSE entry=6 amount=-50
count=3 found=TRUE notfound=FALSE entry=7 amount=200
count=4 found=TRUE notfound=FALSE entry=8 amount=-150
count=5 found=TRUE notfound=FALSE entry=9 amount=400
count=6 found=TRUE notfound=FALSE entry=10 amount=-250
count=7 found=TRUE notfound=FALSE entry=11 amount=600
count=8 found=TRUE notfound=FALSE entry=12 amount=-350
count=9 found=TRUE notfound=FALSE entry=13 amount=800
rows fetched: 9

PL/SQL procedure successfully completed.


Rollback complete.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 account_cursor_session.py

Four lines carry most of the pattern. CURSOR c_ledger (p_account INT) IS SELECT entry_id, amount, memo FROM ledger WHERE account_id = p_account ORDER BY posted_at, entry_id; declares the cursor and ties it to a query. OPEN c_ledger(2); runs the query, binding p_account to 2. FETCH c_ledger INTO v_entry, v_amount, v_memo; reads one row into three variables; the body uses them. EXIT WHEN c_ledger%NOTFOUND; ends the loop when the last fetch found no row.

Testing %NOTFOUND before the first fetch

Wrong: a loop that starts with IF c_ledger%NOTFOUND THEN EXIT; END IF; and only then fetches. The test reads the previous pass’s FETCH: on the first pass %NOTFOUND is NULL (Oracle’s named-cursor-attribute page), so the IF does not run, and at the end the body runs once more after the FETCH that returned no row. Right: FETCH first, then EXIT WHEN c_ledger%NOTFOUND; as the first line after it.

Checkpoint

Check: The explicit cursor life cycle

  1. Question 1 of 2 Which four moves is an explicit cursor loop built from, in order?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: DECLARE, OPEN, FETCH ... INTO (with EXIT WHEN) and CLOSE

    DECLARE and OPEN set up the cursor; FETCH ... INTO reads one row and EXIT WHEN c%NOTFOUND ends the loop; CLOSE releases the resources.

  2. Question 2 of 2 Which statement about an explicit cursor is true, from Oracle's cursors overview?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is a named pointer to a private SQL area, declared and managed by the block that uses it

    "Explicit" means you name it, declare it and run OPEN, FETCH and CLOSE on it; PL/SQL only writes the loop body.

Four attributes report the state of the cursor

A named cursor carries four values that PL/SQL updates for you. Oracle’s named-cursor-attribute page sets each one out, so a cursor cursor attribute: A value a named cursor carries about its state: %ISOPEN says whether the cursor is open, %FOUND and %NOTFOUND say whether the last FETCH returned a row, and %ROWCOUNT counts how many rows the cursor has fetched so far. report is not a guess but a lookup. The attributes are c%ISOPEN, c%FOUND, c%NOTFOUND and c%ROWCOUNT, and their values depend on three states of the cursor: not open, open but no fetch yet, and open with a last fetch that either returned a row or did not.

State of the cursor %ISOPEN %FOUND %NOTFOUND %ROWCOUNT
Not open (before OPEN, after CLOSE) FALSE INVALID_CURSOR INVALID_CURSOR INVALID_CURSOR
Open, no fetch yet TRUE NULL NULL 0
Open, last FETCH returned a row TRUE TRUE FALSE rows fetched so far
Open, last FETCH returned nothing TRUE FALSE TRUE rows fetched so far

Three rules follow. %ROWCOUNT counts the rows the cursor has fetched so far, not the rows the query will return, so after the fourth of nine fetches it reads 4, not 9. The attributes of a closed cursor raise INVALID_CURSOR (ORA-01001), and a FETCH from a closed cursor raises the same exception: Oracle’s error-help page for the code reads “cursor number is invalid or does not exist”. And %ISOPEN of an explicit cursor can be either value, while the implicit SQL%ISOPEN is always FALSE, because PL/SQL closes an implicit cursor at the end of its statement.

The model applies these rules to the nine fetches of account_cursor.sql and prints the table step by step; press Edit, set ACCOUNT_LEDGER_ROWS to 3, and run it again:

cursor_model.py: the attribute table, step by step Python · model/cursor_model.py
"""The documented attribute rules of an explicit cursor, and fetch counts of two loop shapes.

A model, not a run: it applies the attribute rules of Oracle's named-cursor-attribute page to
the rows the lesson's scripts hold, so the numbers the text quotes stay checkable without an
Oracle server. For the cursor FOR loop at PLSQL_OPTIMIZE_LEVEL 2 it assumes an array fetch of
ARRAY_FETCH_LIMIT rows per call; no Oracle page states that size, and fetch_counts.sql reads
the real fetch calls from V$SQL. Edit ACCOUNT_LEDGER_ROWS to add or remove rows for account 2,
LARGE_ROWS_FOR for the size of the FOR loop's result, or ARRAY_FETCH_LIMIT for another batch.
"""

from __future__ import annotations

ACCOUNT_LEDGER_ROWS = 9           # account 2's rows after account_cursor.sql inserts eight extra entries
ARRAY_FETCH_LIMIT = 100           # the model's assumed batch of a cursor FOR loop at PLSQL_OPTIMIZE_LEVEL 2
LARGE_ROWS_FOR = 1_001            # fetch_counts.sql: the cursor FOR loop reads 1,001 rows
LARGE_ROWS_EXPLICIT = 1_002       # fetch_counts.sql: the explicit FETCH loop reads 1,002 rows


def attributes(step: str, is_open: bool, rowcount: int | None, last_returned_row: bool | None) -> dict:
    """The four values an open or closed explicit cursor's attributes carry at one step."""
    if not is_open:
        if step == "before OPEN":
            return {"step": step, "%ISOPEN": "FALSE", "%FOUND": "INVALID_CURSOR", "%NOTFOUND": "INVALID_CURSOR", "%ROWCOUNT": "INVALID_CURSOR"}
        return {"step": step, "%ISOPEN": "FALSE", "%FOUND": "INVALID_CURSOR", "%NOTFOUND": "INVALID_CURSOR", "%ROWCOUNT": "INVALID_CURSOR"}
    if last_returned_row is None:                # the cursor is open but no fetch has run yet
        return {"step": step, "%ISOPEN": "TRUE", "%FOUND": "NULL", "%NOTFOUND": "NULL", "%ROWCOUNT": str(rowcount or 0)}
    return {
        "step": step,
        "%ISOPEN": "TRUE",
        "%FOUND": "TRUE" if last_returned_row else "FALSE",
        "%NOTFOUND": "FALSE" if last_returned_row else "TRUE",
        "%ROWCOUNT": str(rowcount),
    }


def table_for_account(n_rows: int) -> list[dict]:
    """The attribute table of an explicit FETCH loop over n_rows: before OPEN, after each FETCH, after CLOSE."""
    steps = [attributes("before OPEN", is_open=False, rowcount=None, last_returned_row=None)]
    steps.append(attributes("after OPEN", is_open=True, rowcount=0, last_returned_row=None))
    for k in range(1, n_rows + 1):
        steps.append(attributes(f"after FETCH {k}", is_open=True, rowcount=k, last_returned_row=True))
    steps.append(attributes(f"after FETCH {n_rows + 1}", is_open=True, rowcount=n_rows, last_returned_row=False))
    steps.append(attributes("after CLOSE", is_open=False, rowcount=None, last_returned_row=None))
    return steps


def print_table(rows: list[dict]) -> None:
    widths = {k: max(len(k), max(len(r[k]) for r in rows)) for k in rows[0]}
    header = "  ".join(k.ljust(widths[k]) for k in rows[0])
    print(header)
    print("  ".join("-" * widths[k] for k in rows[0]))
    for r in rows:
        print("  ".join(r[k].ljust(widths[k]) for k in r))


def fetch_calls_explicit(n_rows: int) -> int:
    """One FETCH per row returns a row; one more FETCH returns none and ends the loop: n + 1."""
    return n_rows + 1


def fetch_calls_for_loop(n_rows: int, limit: int) -> int:
    """The model's array fetch: full batches of limit rows, then one call that returns the rest or nothing."""
    return n_rows // limit + 1


print("Attributes step by step, for a FETCH loop over account 2's", ACCOUNT_LEDGER_ROWS, "rows:")
print_table(table_for_account(ACCOUNT_LEDGER_ROWS))
print()
print("Fetch calls (one call means one round trip across the PL/SQL and SQL engines):")
print(f"  explicit FETCH loop, {ACCOUNT_LEDGER_ROWS} rows: {fetch_calls_explicit(ACCOUNT_LEDGER_ROWS)}")
print(f"  cursor FOR loop at level 2, {ACCOUNT_LEDGER_ROWS} rows, LIMIT {ARRAY_FETCH_LIMIT}: "
      f"{fetch_calls_for_loop(ACCOUNT_LEDGER_ROWS, ARRAY_FETCH_LIMIT)}")
print(f"  explicit FETCH loop, {LARGE_ROWS_EXPLICIT:,} rows: {fetch_calls_explicit(LARGE_ROWS_EXPLICIT):,}")
print(f"  cursor FOR loop at level 2, {LARGE_ROWS_FOR:,} rows, LIMIT {ARRAY_FETCH_LIMIT}: "
      f"{fetch_calls_for_loop(LARGE_ROWS_FOR, ARRAY_FETCH_LIMIT)}")
print(f"  cursor FOR loop at level 1 (no array fetch), {LARGE_ROWS_FOR:,} rows: "
      f"{fetch_calls_explicit(LARGE_ROWS_FOR):,}")

Output

Attributes step by step, for a FETCH loop over account 2's 9 rows:
step            %ISOPEN  %FOUND          %NOTFOUND       %ROWCOUNT     
--------------  -------  --------------  --------------  --------------
before OPEN     FALSE    INVALID_CURSOR  INVALID_CURSOR  INVALID_CURSOR
after OPEN      TRUE     NULL            NULL            0             
after FETCH 1   TRUE     TRUE            FALSE           1             
after FETCH 2   TRUE     TRUE            FALSE           2             
after FETCH 3   TRUE     TRUE            FALSE           3             
after FETCH 4   TRUE     TRUE            FALSE           4             
after FETCH 5   TRUE     TRUE            FALSE           5             
after FETCH 6   TRUE     TRUE            FALSE           6             
after FETCH 7   TRUE     TRUE            FALSE           7             
after FETCH 8   TRUE     TRUE            FALSE           8             
after FETCH 9   TRUE     TRUE            FALSE           9             
after FETCH 10  TRUE     FALSE           TRUE            9             
after CLOSE     FALSE    INVALID_CURSOR  INVALID_CURSOR  INVALID_CURSOR

Fetch calls (one call means one round trip across the PL/SQL and SQL engines):
  explicit FETCH loop, 9 rows: 10
  cursor FOR loop at level 2, 9 rows, LIMIT 100: 1
  explicit FETCH loop, 1,002 rows: 1,003
  cursor FOR loop at level 2, 1,001 rows, LIMIT 100: 11
  cursor FOR loop at level 1 (no array fetch), 1,001 rows: 1,002

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cursor_model.py

The second script catches the error on purpose: it closes the cursor and then fetches from it:

closed_cursor.sql: a FETCH after CLOSE raises INVALID_CURSOR SQL · cursor/closed_cursor.sql
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- A FETCH after CLOSE raises INVALID_CURSOR (ORA-01001). Oracle's cursors overview page states that an attribute
-- or FETCH on a closed cursor raises INVALID_CURSOR, and ora-01001.txt names the code. Runs on the practice schema
-- (set up by schema_oracle.sql of practice-schema-and-dialect-basics first); nothing is changed, nothing to roll back.
SET SERVEROUTPUT ON
SET LINESIZE 120

DECLARE
  CURSOR c_savings IS
    SELECT account_id, balance
    FROM accounts
    WHERE kind = 'savings'
    ORDER BY account_id;
  v_account accounts.account_id%TYPE;
  v_balance accounts.balance%TYPE;
BEGIN
  OPEN c_savings;
  FETCH c_savings INTO v_account, v_balance;
  DBMS_OUTPUT.PUT_LINE('open: account ' || v_account || ' at ' || v_balance);
  CLOSE c_savings;                                       -- the cursor is gone from this line on
  BEGIN
    FETCH c_savings INTO v_account, v_balance;           -- raises INVALID_CURSOR, caught below
  EXCEPTION
    WHEN INVALID_CURSOR THEN
      DBMS_OUTPUT.PUT_LINE('fetch after close: SQLCODE ' || SQLCODE || ', ' || SUBSTR(SQLERRM, 1, 60));
  END;
END;
/
-- Expected: a line about the first savings account, then "fetch after close: SQLCODE -1001, ORA-01001: ...".
closed_cursor.sql, run in the lab Python · closed_cursor_session.py
"""closed_cursor.sql in the lab: SQL*Plus runs it as LAB on a freshly loaded practice schema."""

from oracle_session import Lab

with Lab() as lab:
    lab.sqlplus("cursor/closed_cursor.sql")

Output

$ sqlplus -s lab@//localhost/FREEPDB1 @cursor/closed_cursor.sql
open: account 1 at 2500
fetch after close: SQLCODE -1001, ORA-01001: cursor number is invalid or does not exist

PL/SQL procedure successfully completed.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 closed_cursor_session.py

The exception handler prints SQLCODE −1001 and the message of ORA-01001, then ends the block.

Checkpoint

Check: Cursor attributes and INVALID_CURSOR

  1. Question 1 of 2 A cursor has fetched four rows of a result set that contains nine. What does c%ROWCOUNT read right after the fourth FETCH?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 4

    Oracle's named-cursor-attribute page says c%ROWCOUNT is the number of rows fetched so far, not the total number of rows the query will return.

  2. Question 2 of 2 A block runs CLOSE c; and then FETCH c INTO v;. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: INVALID_CURSOR (ORA-01001) is raised at the FETCH

    The error code is -1001 and the message is "cursor number is invalid or does not exist"; open the cursor again before any further FETCH.

Parameters and a cursor FOR loop close the shape

Oracle’s explicit-cursor-declaration page lets a cursor take formal parameters. They behave like IN parameters to a subprogram and can declare default values, so one cursor serves many accounts: CURSOR c_ledger (p_account INT DEFAULT 2) IS SELECT ... works with OPEN c_ledger; to list account 2 and with OPEN c_ledger(3); to list account 3. The first example declares its cursor this way and opens it with OPEN c_ledger;.

The same page says the datatype of a cursor parameter cannot have constraints: no NOT NULL, no length for a string and no precision or scale for a number. Declare the bare type, such as INT or VARCHAR2, and check the value in the body if you must. The query reads its parameters and variables when the cursor opens, so changing a variable afterwards does not change the result; to see a new result, CLOSE the cursor, change the value and OPEN it again, as Oracle’s cursors overview shows.

Then the loop. The explicit shape, in full, is:

Interactive · stage by stage An explicit cursor loop in seven stages Follow the stages of the explicit shape and see which of them a cursor FOR loop writes for you.
An explicit cursor loop in seven stages, step 7 of 7. Showing DECLARE the cursor, OPEN the cursor, LOOP, FETCH ... INTO, EXIT WHEN c%NOTFOUND, Work on one row, END LOOP, CLOSE the cursor. In focus: CLOSE the cursor.fetch againno row leftDECLARE the cursorname and queryOPEN the cursorquery runsLOOPfirst pass startsFETCH ... INTOone row into variablesEXIT WHEN c%NOTFOUNDno row then leaveWork on one rowthe lines of the passEND LOOPback to the topCLOSE the cursorrelease resources

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Declare the cursor and its query. CURSOR c_ledger (p_account INT DEFAULT 2) IS SELECT entry_id, amount, memo FROM ledger WHERE account_id = p_account ORDER BY posted_at, entry_id.
  2. OPEN binds and runs the query. OPEN c_ledger(2) binds p_account to 2 and runs the query. The result set is ready, no row read yet.
  3. One FETCH per pass of the LOOP. FETCH c_ledger INTO v_entry, v_amount, v_memo reads the next row into three variables. The first pass reads entry 2 of the ledger.
  4. Test the fetch, exit on no row. EXIT WHEN c_ledger%NOTFOUND leaves the loop as soon as the last FETCH returns no row. Place it right after the FETCH, so the test reads this fetch.
  5. The body uses the fetched values. The pass runs on the row: it may DBMS_OUTPUT.PUT_LINE the entry, add the amount to a running total or call a procedure. Keep the body short.
  6. END LOOP goes back to FETCH. END LOOP sends control back for the next pass. The cursor FOR loop would hide this and the three above with FOR r IN c_ledger(2).
  7. Close the cursor and release it. CLOSE c_ledger releases the resources the cursor held. A cursor FOR loop closes the cursor for you, even if an exception leaves the loop.

A cursor FOR loop: A loop that writes an explicit cursor's OPEN, FETCH, EXIT and CLOSE for you, with the fetched row available as a %ROWTYPE record; at the default optimize level 2 the compiler reads rows in batches on the way in. hides every line except the body. FOR r IN c_ledger(2) LOOP ... END LOOP; declares r as a c_ledger%ROWTYPE record, opens the cursor, fetches one row into r per pass, exits when the result is exhausted and closes the cursor; r.entry_id, r.amount and r.memo name the columns the query selected. If the query appears only once in the unit, write it inline as FOR r IN (SELECT ... FROM ledger WHERE account_id = 2 ORDER BY posted_at, entry_id) LOOP ... END LOOP;, the implicit cursor FOR loop; PL/SQL manages the cursor itself, and you cannot read its attributes.

Oracle’s processing-query-result-sets page calls the explicit OPEN, FETCH and CLOSE shape more complicated than the others but more flexible, the one to use for full control over the result set. When either shape would do, the cursor FOR loop is the better default: PL/SQL writes the OPEN, FETCH, EXIT WHEN and CLOSE for you. The same page notes that an exception inside a cursor FOR loop closes the cursor before the handler runs, so its attributes are not available in the handler.

Checkpoint

Check: Parameters and cursor FOR loops

  1. Question 1 of 2 What does PL/SQL do for you in FOR r IN c_ledger(2) LOOP ... END LOOP;?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It declares r as a c_ledger%ROWTYPE record and runs OPEN, FETCH, EXIT and CLOSE on the cursor

    A cursor FOR loop writes OPEN, FETCH, EXIT WHEN and CLOSE for you; prefer it unless the body must read the attributes between fetches.

  2. Question 2 of 2 In FOR r IN (SELECT entry_id, amount FROM ledger WHERE account_id = 2) LOOP ..., how do you read the amount?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: r.amount, since r is a %ROWTYPE record of the query

    The implicit cursor of a FOR loop has no name, so the attributes are not available; the record r is.

How many fetch calls a cursor FOR loop makes

The compiler earns its keep inside the cursor FOR loop. Oracle’s PL/SQL optimizer page says the compiler can rearrange code for better performance, and PLSQL_OPTIMIZE_LEVEL is 2 by default (Oracle’s database-reference page for the parameter states the default and that level 2 may move code “relatively far from its original location”). Neither page lists the rearrangements one by one. The one this section looks for is an array fetch, where one call to the SQL engine returns many rows into memory and the loop body iterates over them, and fetch_counts.sql reads the FETCHES column of V$SQL for a run of each shape, so the count comes from the database rather than from a rule:

fetch_counts.sql: V$SQL counts the fetch calls of each loop SQL · fetches/fetch_counts.sql
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- V$SQL counts the fetch calls each loop made. LAB holds DB_DEVELOPER_ROLE, which reads V$STATNAME and V$PARAMETER
-- but not V$SQL (Oracle Security Guide, the role's object privileges), so grant that one view once as SYS AS SYSDBA:
--   ALTER SESSION SET CONTAINER = FREEPDB1;
--   GRANT READ ON SYS.V_$SQL TO LAB;
-- The script creates a scratch table of 1,002 rows, runs a cursor FOR loop over 1,001 of them and an explicit FETCH
-- loop over all 1,002, and reads EXECUTIONS, FETCHES and ROWS_PROCESSED from V$SQL for each query. Then it sets
-- PLSQL_OPTIMIZE_LEVEL to 1 for the session, so the next anonymous block is compiled at level 1, and repeats the FOR
-- loop. Each query carries a string literal in its select list as a tag: the text of the statement keeps a literal,
-- while a comment would depend on how PL/SQL hands the statement's text to the SQL engine. V$SQL adds up the runs of
-- a statement since it was loaded, so the lookups divide by EXECUTIONS and print the counts of one run.
-- cursor_model.py in examples/model assumes about 100 rows per fetch call for the cursor FOR loop at level 2 and
-- prints 11 and 1,003, and close to 1,002 for the FOR loop at level 1; the FETCHES this script prints are the real
-- counts, to set against the model.
SET SERVEROUTPUT ON
SET LINESIZE 160
COLUMN sql_text FORMAT A60

DROP TABLE IF EXISTS fc_rows PURGE;
CREATE TABLE fc_rows (id NUMBER(6) PRIMARY KEY, amount NUMBER(10, 2) NOT NULL);
INSERT INTO fc_rows (id, amount)
SELECT LEVEL, LEVEL * 10 FROM dual CONNECT BY LEVEL <= 1002;
COMMIT;

-- Level 2 is the default of PLSQL_OPTIMIZE_LEVEL (Oracle Database Reference). The model assumes the cursor FOR loop
-- reads about 100 rows per fetch call, so 1,001 rows would take about 11 calls; the V$SQL query below shows the count.
BEGIN
  FOR r IN (SELECT 'fc_for_level2' AS tag, id, amount FROM fc_rows WHERE id <= 1001 ORDER BY id) LOOP
    NULL;                                                 -- no body, so only the loop's work is measured
  END LOOP;
END;
/

DECLARE
  CURSOR c IS SELECT 'fc_explicit_level2' AS tag, id, amount FROM fc_rows WHERE id <= 1002 ORDER BY id;
  v_tag    VARCHAR2(20);
  v_id     fc_rows.id%TYPE;
  v_amount fc_rows.amount%TYPE;
BEGIN
  OPEN c;
  LOOP
    FETCH c INTO v_tag, v_id, v_amount;
    EXIT WHEN c%NOTFOUND;
    NULL;
  END LOOP;
  CLOSE c;
END;
/

-- command_type 3 keeps the SELECT statements, because the anonymous blocks hold the tags too; the NOT LIKE leaves out
-- this query, whose own text also holds them.
SELECT substr(sql_text, 1, 60) AS sql_text, executions,
       ROUND(fetches / NULLIF(executions, 0)) AS fetches_per_run,
       ROUND(rows_processed / NULLIF(executions, 0)) AS rows_per_run
FROM v$sql
WHERE command_type = 3
AND   sql_text NOT LIKE '%v$sql%'
AND   (sql_text LIKE '%''fc_for_level2''%' OR sql_text LIKE '%''fc_explicit_level2''%');
-- Expected, if the cursor FOR loop fetches about 100 rows a call as the model assumes: fetches_per_run 11 for the
-- fc_for_level2 query and 1003 for fc_explicit_level2, rows_per_run 1001 and 1002.

ALTER SESSION SET PLSQL_OPTIMIZE_LEVEL = 1;

BEGIN
  FOR r IN (SELECT 'fc_for_level1' AS tag, id, amount FROM fc_rows WHERE id <= 1001 ORDER BY id) LOOP
    NULL;
  END LOOP;
END;
/

SELECT substr(sql_text, 1, 60) AS sql_text, executions,
       ROUND(fetches / NULLIF(executions, 0)) AS fetches_per_run,
       ROUND(rows_processed / NULLIF(executions, 0)) AS rows_per_run
FROM v$sql
WHERE command_type = 3
AND   sql_text NOT LIKE '%v$sql%'
AND   sql_text LIKE '%''fc_for_level1''%';
-- Expected, if level 1 leaves out the array fetch as the model assumes: fetches_per_run close to 1002 (one per row
-- and a final empty fetch), rows_per_run 1001.

-- back to the default before you run the next script
ALTER SESSION SET PLSQL_OPTIMIZE_LEVEL = 2;
DROP TABLE fc_rows PURGE;
fetch_counts.sql, run in the lab Python · fetch_counts_session.py
"""fetch_counts.sql in the lab as LAB, after the one grant its header names."""

from oracle_session import Lab

with Lab() as lab:
    lab.sysdba("GRANT READ ON SYS.V_$SQL TO lab;", note="once, as SYS in FREEPDB1")
    lab.sqlplus("fetches/fetch_counts.sql")

Output

-- once, as SYS in FREEPDB1
$ sqlplus -s / as sysdba <<'SQL'
ALTER SESSION SET CONTAINER = FREEPDB1;
GRANT READ ON SYS.V_$SQL TO lab;
SQL
Session altered.


Grant succeeded.

$ sqlplus -s lab@//localhost/FREEPDB1 @fetches/fetch_counts.sql
Table dropped.


Table created.


1002 rows created.


Commit complete.


PL/SQL procedure successfully completed.


PL/SQL procedure successfully completed.


SQL_TEXT                                                     EXECUTIONS FETCHES_PER_RUN ROWS_PER_RUN
------------------------------------------------------------ ---------- --------------- ------------
SELECT 'fc_for_level2' AS TAG, ID, AMOUNT FROM FC_ROWS WHERE          1              11         1001
SELECT 'fc_explicit_level2' AS TAG, ID, AMOUNT FROM FC_ROWS           1            1003         1002


Session altered.


PL/SQL procedure successfully completed.


SQL_TEXT                                                     EXECUTIONS FETCHES_PER_RUN ROWS_PER_RUN
------------------------------------------------------------ ---------- --------------- ------------
SELECT 'fc_for_level1' AS TAG, ID, AMOUNT FROM FC_ROWS WHERE          1            1002         1001


Session altered.


Table dropped.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fetch_counts_session.py

The explicit FETCH ... INTO loop over 1,002 rows makes 1,003 calls, one for each FETCH statement it runs, the last returning nothing. For the cursor FOR loop the model assumes batches of about 100 rows, which gives 11 calls for 1,001 rows at level 2: ten full batches and one of the remainder. After ALTER SESSION SET PLSQL_OPTIMIZE_LEVEL = 1, which the script runs before its last block, it expects the cursor FOR loop to fetch row by row, close to 1,002 calls. These are the model’s counts, and the FETCHES figures the script prints in your lab are the measurement. Press Edit, set LARGE_ROWS_FOR = 10_000, and run the model again:

Loop shape Rows Fetch calls, by the model
Explicit loop, 9 rows (account 2) 9 10
Cursor FOR loop at level 2, 1,001 rows 1,001 11
Explicit loop, 1,002 rows 1,002 1,003
Cursor FOR loop at level 1 (no array fetch), 1,001 rows 1,001 1,002

Each fetch call crosses between the PL/SQL and SQL engines, so fewer calls mean less work for the server even when the row count is the same. For many thousands of rows the lesson on BULK COLLECT with LIMIT (plsql-bulk-collect-and-limit) teaches an explicit batch fetch of whatever size the data needs, with the same gain. Cursor variables, REF CURSOR, which hand a result set to another unit, are a later topic of the module on subprograms and packages.

The four shapes the module teaches line up by what each one is for:

Interactive · answer and compare Explicit loop, cursor FOR loop, implicit FOR loop or BULK COLLECT? Three answers about your loop sort the four shapes into good fits, fits with a catch, and shapes to avoid.
Is the query shared with another unit of code?
Must the code run its own FETCH statements, or keep the cursor open across statements?
How many rows will the loop handle?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four shapes for reading many rows from PL/SQL, against what each controls and the lines it writes for you
Shape Lines PL/SQL writes for you Control between fetches Fetch calls to the SQL engine
Explicit loop Written as: OPEN; LOOP FETCH; EXIT; END LOOP; CLOSE Poor none: write OPEN, LOOP, FETCH, EXIT, CLOSE yourself Good full: your own FETCH, the attributes, the cursor left open Poor one per row plus a final empty fetch
Cursor FOR loop Written as: FOR r IN c_ledger(2) LOOP Good OPEN, FETCH, EXIT WHEN and CLOSE Mixed no FETCH of your own; the cursor closes when the loop ends Good array fetches at level 2; the model assumes about 100 rows a call
Implicit FOR loop Written as: FOR r IN (SELECT ...) LOOP Good the whole DECLARE too; one FOR line Poor none: the cursor has no name to read Good the same array fetch as a cursor FOR loop
BULK COLLECT Written as: FETCH ... BULK COLLECT INTO ... LIMIT n Mixed the batch loop, but not row-by-row FETCH Good yes, on the batch between fetches Good one call per batch of LIMIT rows
How the answers decide
  • “Is the query shared with another unit of code?” Yes, declared once: rules out Implicit FOR loop. An implicit FOR loop writes the query inline, so it cannot be shared with another unit of code.
  • “Must the code run its own FETCH statements, or keep the cursor open across statements?” Yes, it needs that control: rules out Cursor FOR loop and Implicit FOR loop. Only the explicit shapes let the code run its own FETCH, skip rows or split the work over several loops, as Oracle lists for OPEN, FETCH and CLOSE.
  • “Must the code run its own FETCH statements, or keep the cursor open across statements?” No, one pass per row: adds a caveat to Explicit loop. The explicit loop still works, but it writes by hand the OPEN, FETCH, EXIT WHEN and CLOSE that a FOR loop writes for you.
  • “How many rows will the loop handle?” Tens of thousands or more: rules out Explicit loop. One FETCH call per row grows with the row count; BULK COLLECT keeps the same control and fetches a batch per call.
  • “How many rows will the loop handle?” Tens of thousands or more and “Must the code run its own FETCH statements, or keep the cursor open across statements?” No, one pass per row: adds a caveat to Cursor FOR loop and Implicit FOR loop. The compiler, not the code, decides the array fetch of a cursor FOR loop; BULK COLLECT with LIMIT lets the code choose a batch that fits the data.

Key idea

Prefer the cursor FOR loop for ordinary reads: it writes the cursor’s moves for you, and at the default optimize level it is expected to fetch in batches, which fetch_counts.sql counts in V$SQL.

Checkpoint

Check: Array fetch at optimize level 2

  1. Question 1 of 2 A cursor FOR loop at PLSQL_OPTIMIZE_LEVEL 2 reads a result set of 1,001 rows. With the lesson model's rule of an array fetch of about 100 rows, how many fetch calls does it predict?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 11

    Ten batches of 100 rows plus one batch of the remaining 1, as cursor_model.py prints; fetch_counts.sql reads the real count from the FETCHES column of V$SQL when you run it in the lab.

  2. Question 2 of 2 What is the default value of the PLSQL_OPTIMIZE_LEVEL parameter?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2

    Level 2 lets the compiler move code relatively far from its source order, and level 1 generally keeps that order; fetch_counts.sql compares a cursor FOR loop's fetch calls at the two levels.

FOR UPDATE locks rows at open and WHERE CURRENT OF changes one

A cursor that reads rows the block will change needs a lock, or another session can change them in between. Oracle’s transaction page defines the shape: SELECT ... FOR UPDATE in a cursor’s query makes it a FOR UPDATE cursor: An explicit cursor whose SELECT ends with FOR UPDATE: Oracle locks every row of the result set when the cursor opens, and WHERE CURRENT OF changes the row the cursor last fetched until a COMMIT or ROLLBACK releases the locks., and OPEN locks every row the result set contains. A later UPDATE ... WHERE CURRENT OF c or DELETE ... WHERE CURRENT OF c changes exactly the row the cursor last fetched, so there is no second WHERE clause to keep in step. COMMIT or ROLLBACK releases the locks, and from that point on a fetch from the cursor raises ORA-01002, “fetch out of sequence”.

The script for_update.sql opens a FOR UPDATE cursor over the three savings accounts, raises each balance by 50.00 with WHERE CURRENT OF, prints each new balance and ends with a ROLLBACK so the lab’s data is unchanged. Its second block runs the same loop with a COMMIT inside, which fails on the next FETCH:

for_update.sql: a safe loop, the COMMIT-inside-the-loop bug and the fix SQL · locking/for_update.sql
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- A FOR UPDATE cursor locks the result rows at OPEN and WHERE CURRENT OF changes the row the cursor last fetched.
-- Oracle's transaction-processing page states that the rows unlock on COMMIT or ROLLBACK and that a fetch after
-- a COMMIT raises ORA-01002, "fetch out of sequence" (ora-01002.txt). This script shows a safe loop that raises each
-- savings balance by 50.00, the COMMIT-inside-the-loop bug that breaks the next FETCH, and the fix. ROLLBACK at the
-- end leaves the lab's data unchanged. Runs on the practice schema (set up by schema_oracle.sql of
-- practice-schema-and-dialect-basics first).
SET SERVEROUTPUT ON
SET LINESIZE 120

-- The safe loop: FOR UPDATE at OPEN, WHERE CURRENT OF for each change, one COMMIT (or ROLLBACK) outside the loop.
DECLARE
  CURSOR c_savings IS
    SELECT account_id, balance
    FROM accounts
    WHERE kind = 'savings'
    ORDER BY account_id
    FOR UPDATE OF balance;
  v_rise accounts.balance%TYPE := 50.00;
  v_account accounts.account_id%TYPE;
  v_balance accounts.balance%TYPE;
BEGIN
  OPEN c_savings;                                              -- locks every row of the result at this point
  LOOP
    FETCH c_savings INTO v_account, v_balance;
    EXIT WHEN c_savings%NOTFOUND;
    UPDATE accounts
    SET balance = balance + v_rise
    WHERE CURRENT OF c_savings;                                -- changes the row this cursor last fetched
    DBMS_OUTPUT.PUT_LINE('safe loop: account ' || v_account || ' now at ' || (v_balance + v_rise));
  END LOOP;
  CLOSE c_savings;
END;
/
-- Expected: three lines showing accounts 1, 3 and 4 raised to 2550, 810.25 and 41300.5.

-- undo the raise before the next block
ROLLBACK;

-- The bug: a COMMIT inside the loop releases the locks, so the next FETCH raises ORA-01002.
DECLARE
  CURSOR c_savings IS
    SELECT account_id, balance
    FROM accounts
    WHERE kind = 'savings'
    ORDER BY account_id
    FOR UPDATE OF balance;
  v_rise accounts.balance%TYPE := 50.00;
  v_account accounts.account_id%TYPE;
  v_balance accounts.balance%TYPE;
BEGIN
  OPEN c_savings;
  LOOP
    FETCH c_savings INTO v_account, v_balance;                 -- raises ORA-01002 on the second pass
    EXIT WHEN c_savings%NOTFOUND;
    UPDATE accounts
    SET balance = balance + v_rise
    WHERE CURRENT OF c_savings;
    COMMIT;                                                    -- releases the row locks here
    DBMS_OUTPUT.PUT_LINE('bug loop: account ' || v_account || ' now at ' || (v_balance + v_rise));
  END LOOP;
  CLOSE c_savings;
EXCEPTION
  WHEN OTHERS THEN
    DBMS_OUTPUT.PUT_LINE('caught ' || SQLCODE || ': ' || SUBSTR(SQLERRM, 1, 60));
    IF c_savings%ISOPEN THEN CLOSE c_savings; END IF;
END;
/
-- Expected: one line for account 1, then "caught -1002: ORA-01002: fetch out of sequence".

-- The bug block's COMMIT made its one change, account 1 at 2550, permanent, so a ROLLBACK cannot undo it.
-- Take it back with an UPDATE of its own, and commit that, so the next block starts from the practice data.
UPDATE accounts SET balance = balance - 50.00 WHERE account_id = 1;
COMMIT;

-- The fix: keep the COMMIT out of the loop and run it once, after CLOSE, when every row has been changed.
DECLARE
  CURSOR c_savings IS
    SELECT account_id, balance
    FROM accounts
    WHERE kind = 'savings'
    ORDER BY account_id
    FOR UPDATE OF balance;
  v_rise    accounts.balance%TYPE := 50.00;
  v_account accounts.account_id%TYPE;
  v_balance accounts.balance%TYPE;
BEGIN
  OPEN c_savings;
  LOOP
    FETCH c_savings INTO v_account, v_balance;
    EXIT WHEN c_savings%NOTFOUND;
    UPDATE accounts
    SET balance = balance + v_rise
    WHERE CURRENT OF c_savings;
    DBMS_OUTPUT.PUT_LINE('fix loop: account ' || v_account || ' now at ' || (v_balance + v_rise));
  END LOOP;
  CLOSE c_savings;
  -- COMMIT;                                                   -- in production, commit here; the script ends with ROLLBACK
END;
/
-- Expected: the same three lines the safe loop printed.

-- leaves the practice schema as it was
ROLLBACK;
for_update.sql, run in the lab Python · for_update_session.py
"""for_update.sql in the lab: SQL*Plus runs it as LAB on a freshly loaded practice schema."""

from oracle_session import Lab

with Lab() as lab:
    lab.sqlplus("locking/for_update.sql")

Output

$ sqlplus -s lab@//localhost/FREEPDB1 @locking/for_update.sql
safe loop: account 1 now at 2550
safe loop: account 3 now at 810.25
safe loop: account 4 now at 41300.5

PL/SQL procedure successfully completed.


Rollback complete.

bug loop: account 1 now at 2550
caught -1002: ORA-01002: fetch out of sequence

PL/SQL procedure successfully completed.


1 row updated.


Commit complete.

fix loop: account 1 now at 2550
fix loop: account 3 now at 810.25
fix loop: account 4 now at 41300.5

PL/SQL procedure successfully completed.


Rollback complete.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 for_update_session.py

The second block’s output ends with ORA-01002: fetch out of sequence, because the first COMMIT released the locks, and Oracle’s transaction page allows no fetch from a FOR UPDATE cursor once its locks are gone. That COMMIT also made the raise of account 1 permanent, so no ROLLBACK can take it back: the script undoes it with an UPDATE of its own before the third block. The fix in the third block moves the COMMIT out of the loop, so every change the loop made survives together or none of them does.

Change the raise and reread the attributes

In the lab, change the first block’s v_rise from 50.00 to 10.00, re-run the script, and read c_savings%ROWCOUNT after each pass. The counter still reads 1, 2 and then 3 across the three savings accounts, and the balances rise by 10.00 each. Then try the second block with the COMMIT kept and watch ORA-01002 arrive on the next FETCH before any further change is applied.

Checkpoint

Check: FOR UPDATE and WHERE CURRENT OF

  1. Question 1 of 2 For CURSOR c IS SELECT ... FROM accounts WHERE kind = 'savings' FOR UPDATE OF balance;, when do the row locks engage?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: At OPEN c, for every row of the result set

    Rows are released on COMMIT or ROLLBACK; from that moment a FETCH from the cursor raises ORA-01002.

  2. Question 2 of 2 A loop over a FOR UPDATE cursor runs UPDATE ... WHERE CURRENT OF c; and then COMMIT; inside the body. The next FETCH fails. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The COMMIT released the row locks the OPEN took, so the cursor can no longer iterate and the FETCH raises ORA-01002

    Move the COMMIT out of the loop, after CLOSE, as the third block of for_update.sql does.

Practise: predict the attribute values, the counts and the error

Each question returns to a point this lesson teaches on a case close to the practice ledger. Work out which attribute, which loop shape or which error line is right before you choose:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 In the explicit shape, where does EXIT WHEN c%NOTFOUND belong?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: Right after FETCH ... INTO, so the test reads the fetch that just ran

    Pair every FETCH with an EXIT WHEN right after it; a stray EXIT read before the matching FETCH is the shape's commonest mistake.

  2. Question 2 of 10 After OPEN c; a LOOP starts with IF c%NOTFOUND THEN EXIT; END IF; before any FETCH. On the first pass, what does the IF test see?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: NULL, so the IF does nothing and the loop keeps going

    Place the EXIT WHEN right after FETCH ... INTO, so the attribute is the result of a real fetch.

  3. Question 3 of 10 A FOR UPDATE cursor fetches nine rows one by one. The tenth FETCH returns no row and the loop exits. What does c%ROWCOUNT read right before CLOSE?

    Type a number.

    Show the answer: Question 3 of 10

    Answer: 9

    c%ROWCOUNT is the number of rows the cursor has fetched so far; a FETCH that returns no row does not increase it, so it holds its last value until CLOSE; after CLOSE, reading it raises INVALID_CURSOR.

  4. Question 4 of 10 What does CLOSE c add that leaving the cursor open does not?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: It releases the private SQL area the cursor holds, so the resources are back for other work

    A cursor FOR loop runs CLOSE for you even when an exception leaves the loop; the explicit shape needs it in the code.

  5. Question 5 of 10 A cursor is declared CURSOR c (p INT DEFAULT 2) IS SELECT ... WHERE account_id = p;. A block runs OPEN c;. Which account does it read?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: Account 2, from the parameter's default value

    Defaults make one cursor serve several callers; a caller that passes a value overrides the default.

  6. Question 6 of 10 FOR r IN (SELECT ... FROM ledger WHERE account_id = 2) LOOP ... END LOOP; is which shape?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: An implicit cursor FOR loop; the cursor has no name you can reference

    Use the implicit form when the query is used only once in the unit, and the explicit form when the same cursor serves several callers.

  7. Question 7 of 10 A cursor FOR loop at PLSQL_OPTIMIZE_LEVEL 2 reads 250 rows with the array fetch of about 100 rows per call. How many fetch calls does the model predict?

    Type a number.

    Show the answer: Question 7 of 10

    Answer: 3

    Two full batches of 100 rows and one batch of 50; cursor_model.py prints the same count with LARGE_ROWS_FOR set to 250.

  8. Question 8 of 10 A procedure is recompiled with ALTER PROCEDURE ... COMPILE PLSQL_OPTIMIZE_LEVEL = 1. How does a cursor FOR loop over 1,001 rows behave compared with level 2?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: It is expected to fetch row by row, about 1,002 calls instead of 11, which fetch_counts.sql checks in V$SQL

    Recompile at level 2 and run the script again to see the batched count return.

  9. Question 9 of 10 Why does a SELECT ... FOR UPDATE cursor take its locks at OPEN rather than at each FETCH?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: Oracle locks the whole result set when the query runs, which is at OPEN

    One side effect: a cursor over a large result set waits at OPEN until every row is lockable, so a FOR UPDATE cursor over many rows can block other sessions.

  10. Question 10 of 10 A block declares CURSOR c IS SELECT account_id, balance FROM accounts WHERE kind = 'savings'; with no FOR UPDATE, and its loop runs UPDATE accounts SET balance = balance + 50 WHERE CURRENT OF c. What happens?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: It is refused: only a FOR UPDATE cursor can appear in a CURRENT OF clause

    FOR UPDATE locks the rows at OPEN and makes CURRENT OF available; without it, update by key and accept that another session may change the row in between.

Interview questions

Warm-up (fresher): what do c%NOTFOUND and c%ROWCOUNT read before the first FETCH, and after the FETCH past the last row?

Before the first FETCH, Oracle’s named-cursor-attribute page says c%NOTFOUND is NULL and c%ROWCOUNT is 0, so a test such as EXIT WHEN c%NOTFOUND; placed before any fetch does nothing: the condition is NULL, which is neither true nor false, and the loop keeps going. After a FETCH that read a row, c%NOTFOUND is FALSE and c%ROWCOUNT is the number of rows fetched so far. After a FETCH that returned no row, c%NOTFOUND is TRUE and c%ROWCOUNT stays at the last count. The loop’s test belongs right after the FETCH, as EXIT WHEN c%NOTFOUND;, so it reads the fetch it is paired with.

Warm-up (fresher to 2–5 years): how do a cursor FOR loop and an explicit OPEN, FETCH and CLOSE loop differ?

A cursor FOR loop writes FOR r IN c_ledger(2) LOOP ... END LOOP;, and PL/SQL does the rest: it declares r as a record of the cursor’s row type, opens the cursor, fetches a row into r on every pass, exits when the result is exhausted and closes the cursor; the attributes are not available after the loop. The explicit shape writes OPEN c_ledger(2); LOOP FETCH c_ledger INTO v_entry, v_amount, v_memo; EXIT WHEN c_ledger%NOTFOUND; ... END LOOP; CLOSE c_ledger;, which lets the body read the attributes between fetches, name the target variables and leave the cursor open across statements. Prefer the FOR loop for ordinary reads; the explicit shape is for cases that use its control.

Two longer cases follow: an overnight interest job that commits inside a FOR UPDATE loop and crashes, and a quarterly report that reads millions of rows one at a time and runs for an hour. Think through your answer before you open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    An overnight job raises interest on 100,000 fixed-deposit rows with a FOR UPDATE cursor and commits every 1,000 rows inside the loop. The first commit succeeds; the next FETCH fails. Explain the error and rewrite the loop so good batches stay saved and the job keeps going.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    A quarterly report walks 2 million ledger rows with an explicit FETCH ... INTO loop and runs for over an hour. The same loop rewritten as a cursor FOR loop finishes in minutes. Explain where the gain comes from, how you would show it on your own data, and name a case where neither loop finishes in minutes.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What are an explicit cursor's four moves?

    DECLARE ties the name to a query, OPEN runs it, FETCH ... INTO reads one row into variables, and CLOSE releases its resources. EXIT WHEN c%NOTFOUND; is the first line after the FETCH.

  2. Why must the FETCH come before the %NOTFOUND test?

    The test reads the previous pass's fetch. On the first pass %NOTFOUND is NULL, so an IF before any fetch does not exit, and at the end the body runs once more after the fetch that returned no row.

  3. What do a cursor's four attributes read in each state?

    Not open: %ISOPEN FALSE and the other three raise INVALID_CURSOR. Open with no fetch yet: TRUE, NULL, NULL and 0. After a fetch that returned a row: TRUE, TRUE, FALSE and the rows fetched so far; after one that returned nothing: TRUE, FALSE, TRUE and the same count.

  4. What does %ROWCOUNT count, and what does a fetch from a closed cursor raise?

    The rows the cursor has fetched so far, not the rows the query will return: after the fourth of nine fetches it reads 4. A fetch from a closed cursor, like reading its attributes, raises INVALID_CURSOR, ORA-01001.

  5. What are the rules for a cursor's parameters?

    They behave like IN parameters and may have defaults, so one cursor serves many accounts. Their datatype cannot have constraints: no NOT NULL, no string length, no precision or scale. The query reads its parameters and variables when the cursor opens, so a new value needs CLOSE and OPEN again.

  6. What does a cursor FOR loop write for you, and what does it cost?

    It declares its record as c%ROWTYPE, opens the cursor, fetches one row per pass, exits when the result is exhausted and closes the cursor. An exception inside it closes the cursor before the handler runs, so the attributes are not available there; the inline form hides them altogether.

  7. Why do fetch calls matter, and what does fetch_counts.sql measure?

    Each fetch call crosses between the PL/SQL and SQL engines, so fewer calls mean less work even for the same rows. An explicit loop over 1,002 rows runs 1,003 fetch statements; the script reads the FETCHES column of V$SQL for each loop shape, so the counts come from the database rather than from a rule.

  8. What does FOR UPDATE lock, and what ends it?

    OPEN locks every row the result set contains, and UPDATE or DELETE ... WHERE CURRENT OF c changes exactly the row the cursor last fetched, with no second WHERE clause to keep in step. A COMMIT or ROLLBACK releases the locks, and the next FETCH then raises ORA-01002, fetch out of sequence, so the COMMIT belongs outside the loop.

Key takeaways

  • An explicit cursor is a named pointer to a result set; OPEN, FETCH ... INTO, EXIT WHEN c%NOTFOUND; and CLOSE are its four moves, and parameters with defaults make one cursor serve many callers.
  • The attributes %ISOPEN, %FOUND, %NOTFOUND and %ROWCOUNT raise INVALID_CURSOR on a closed cursor, read as NULL and 0 on an open one before the first fetch, and after a fetch report whether it returned a row and how many rows the cursor has fetched so far.
  • A cursor FOR loop writes the OPEN, FETCH, EXIT WHEN and CLOSE for you, and at PLSQL_OPTIMIZE_LEVEL 2 it is expected to read rows in batches, which the lab script measures; prefer it unless the body needs the attributes between fetches.
  • SELECT ... FOR UPDATE in a cursor locks every row of the result when the cursor opens, and UPDATE ... WHERE CURRENT OF c changes the row the cursor last fetched; a COMMIT or ROLLBACK releases the locks and makes the next FETCH raise ORA-01002.
  • BULK COLLECT, the third lesson of this module, reads an explicit batch of any size and keeps the gain the cursor FOR loop gives you, with full control over the memory it costs.

Terms in this lesson

cursor attribute
A value a named cursor carries about its state: %ISOPEN says whether the cursor is open, %FOUND and %NOTFOUND say whether the last FETCH returned a row, and %ROWCOUNT counts how many rows the cursor has fetched so far.
cursor FOR loop
A loop that writes an explicit cursor's OPEN, FETCH, EXIT and CLOSE for you, with the fetched row available as a %ROWTYPE record; at the default optimize level 2 the compiler reads rows in batches on the way in.
explicit cursor
A named session cursor you declare, open, fetch from and close yourself in a PL/SQL block, with four attributes you can read; an implicit cursor, by contrast, is opened by PL/SQL around a SELECT INTO or DML statement.
FOR UPDATE cursor
An explicit cursor whose SELECT ends with FOR UPDATE: Oracle locks every row of the result set when the cursor opens, and WHERE CURRENT OF changes the row the cursor last fetched until a COMMIT or ROLLBACK releases the locks.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.