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.
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Before OPEN the cursor is closed. %ISOPEN is FALSE, and %FOUND, %NOTFOUND and %ROWCOUNT raise INVALID_CURSOR, so the block cannot read them yet.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
-- 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 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
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:
"""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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The second script catches the error on purpose: it closes the cursor and then fetches from it:
-- 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 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
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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).
- 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
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:
-- 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 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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:
-- 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 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
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.
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.
-
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.
-
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.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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;andCLOSEare its four moves, and parameters with defaults make one cursor serve many callers. - The attributes
%ISOPEN,%FOUND,%NOTFOUNDand%ROWCOUNTraiseINVALID_CURSORon a closed cursor, read asNULLand 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 WHENandCLOSEfor you, and atPLSQL_OPTIMIZE_LEVEL2 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 UPDATEin a cursor locks every row of the result when the cursor opens, andUPDATE ... WHERE CURRENT OF cchanges the row the cursor last fetched; aCOMMITorROLLBACKreleases the locks and makes the nextFETCHraise 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
- PL/SQL Language Reference 26ai: Cursors Overview (Oracle)
- PL/SQL Language Reference 26ai: Explicit Cursor Declaration and Definition (Oracle)
- PL/SQL Language Reference 26ai: Named Cursor Attribute (Oracle)
- PL/SQL Language Reference 26ai: Cursor FOR LOOP Statement (Oracle)
- PL/SQL Language Reference 26ai: Processing Query Result Sets (Oracle)
- PL/SQL Language Reference 26ai: Transaction Processing and Control (Oracle)
- PL/SQL Language Reference 26ai: PL/SQL Optimizer (Oracle)
- Database Reference 26ai: PLSQL_OPTIMIZE_LEVEL (Oracle)
- PL/SQL Language Reference 26ai: Predefined Exceptions (Oracle)
- Database Error Messages: ORA-01001 (Oracle)
- Database Error Messages: ORA-01002 (Oracle)
Report a problem with this lesson
Kept only in this browser. Your Learn progress