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

FORALL, SAVE EXCEPTIONS and DML error logging

Send many inserts or updates in one call with FORALL, see what one bad row does to a bulk load, and keep loading past bad rows in PL/SQL.

  • Intermediate
  • 25 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 core, Pro depth: 8 of 25 minutes free
Free: FORALL sends one DML statement with a whole collection of values · Set-based SQL first, and FORALL when each row needs procedural logic · One failing element stops the FORALL. With a pass: 2 in-depth parts and 3 interview answers, about 17 minutes.

What you will learn

  • Send many DML statements in one call with FORALL
  • Report per-row errors with SAVE EXCEPTIONS and SQL%BULK_EXCEPTIONS
  • Count the rows each element changed with SQL%BULK_ROWCOUNT
  • Choose between FORALL, set-based SQL and DML error logging

Before you start

A loop that loads a feed of 200,000 ledger entries with one INSERT per entry sends 200,000 statements from PL/SQL to the SQL engine. FORALL sends the INSERT once per batch, with the batch’s values bound to it. For the bad entries a feed brings, SAVE EXCEPTIONS lets the FORALL finish its batch and report each failure, and DML error logging lets one INSERT … SELECT write each refused row to a table and carry on.

You will load the ledger in bulk, choose between one SQL statement and FORALL, and see what one bad entry does by default.

FORALL sends one DML statement with a whole collection of values

FORALL statement: A PL/SQL statement that sends one INSERT, UPDATE, DELETE or MERGE to the SQL engine in a single call, with the values of a whole collection bound to it; the engine runs it once for each index in its bounds. is written like a FOR loop, but it is one statement: it binds collections to a single INSERT, UPDATE, DELETE or MERGE and sends it to the SQL engine in one call, and the engine runs it once for each index in the bounds. It is the sending half of bulk SQL: Oracle's two features that move rows between PL/SQL and SQL in batches: BULK COLLECT for reads and FORALL for DML, which cut the number of context switches a loop pays. More in “BULK COLLECT with LIMIT”, and BULK COLLECT is the fetching half. Follow three entries of a feed into the ledger, written three ways:

Interactive · message by message Three entries written three ways Step through a row-by-row loop, a FORALL and an INSERT ... SELECT, and count the calls each one sends to the SQL engine.
Three entries written three ways, step 7 of 7. Messages so far: PL/SQL to SQL engine: INSERT entry 1; PL/SQL to SQL engine: INSERT 2, INSERT 3; PL/SQL to SQL engine: INSERT with 3 rows; SQL engine to ledger: 3 rows written; PL/SQL to SQL engine: INSERT ... SELECT; SQL engine to ledger: 3 rows written.PL/SQLset-basedSQL enginecalls: 1ledgerINSERT entry 1INSERT 2, INSERT 3INSERT with 3 rows3 rows writtenINSERT ... SELECT3 rows written

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

Every step as text
  1. Three entries in a collection. BULK COLLECT has filled v_feed with the first three entries of the feed: account 1 +1200.00, account 3 -150.00 and account 2 +4800.00. Each way below writes the same three rows.
  2. Row by row: the first call. A FOR loop runs its INSERT once per pass. The first pass sends one INSERT with the values of entry 1, and the SQL engine writes one row.
  3. Row by row: calls 2 and 3. Passes 2 and 3 send one INSERT each. Three rows have cost three calls, and 200,000 rows would cost 200,000.
  4. FORALL: one call. FORALL i IN 1 .. v_feed.COUNT sends the INSERT once, with all three sets of values bound to it. A batch of 1,000 entries needs this one call too.
  5. The engine runs it per element. The SQL engine runs the statement for each index from 1 to 3 and writes three rows. The work per row stays the same; the trips between PL/SQL and SQL are gone.
  6. INSERT ... SELECT: no loop. When the entries sit in a staging table and SQL can compute every column, one INSERT ... SELECT does the job. PL/SQL sends one statement and never holds the rows.
  7. The engine reads and writes. The SQL engine reads the staged rows and writes them inside the same statement: one call for 3 rows, and one call for 200,000.

The bounds, 1 .. 3 in the figure, pick the elements. 1 .. v_feed.COUNT needs every index of the range, so a deleted element raises an exception; INDICES OF v_feed takes only the elements that exist, for a collection with gaps, and VALUES OF v_pick runs the elements whose numbers another collection holds. Oracle’s bulk SQL and FORALL pages set the limits: one DML statement per FORALL, the index only as a subscript and never inside an expression, and the same indexes in every collection the statement reads.

Checkpoint

Check: FORALL, one statement with a batch of values

  1. Question 1 of 2 v_ids holds elements 1, 2, 4 and 5, because element 3 was deleted. Which FORALL runs its UPDATE for exactly those four elements?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: FORALL i IN INDICES OF v_ids

    A range needs every index from its lower to its upper bound. For a collection with gaps, INDICES OF runs only the elements that exist, and VALUES OF runs the elements whose numbers another collection holds.

  2. Question 2 of 2 A FOR loop inserts a ledger row and then updates the account's balance on each pass. How is the same work written with FORALL?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Two FORALL statements over the same collections, one for the INSERT and one for the UPDATE

    FORALL runs one DML statement many times with different values, so a loop body of two statements becomes two FORALL statements over the same collections.

Set-based SQL first, and FORALL when each row needs procedural logic

Copying 200,000 generated rows three ways, with a cursor FOR loop, in batches and in one statement, shows the order to try them in. The model counts the INSERT calls of each way; press Edit, set LIMIT to 300 and run it again:

INSERT calls for 200,000 rows, three ways Python · model/three_ways_model.py
"""Calls from PL/SQL to the SQL engine for the three copies of three_ways.sql.

What it models: three ways to copy ROWS rows, counted as Oracle's page on
bulk SQL and bulk binding describes them. A cursor FOR loop with an INSERT
inside sends one INSERT per row. FETCH ... BULK COLLECT INTO ... LIMIT with
FORALL sends one FETCH and one INSERT call per batch, and each INSERT call
carries the values of the whole batch; the loop ends on the first FETCH that
returns no row. One INSERT ... SELECT is a single statement: the SQL engine
reads and writes the rows itself, and PL/SQL holds none of them.
It counts calls only: elapsed times depend on the machine, so the script
prints those on yours.
What to edit: ROWS and LIMIT, then run it again.
"""

ROWS = 200_000
LIMIT = 1_000


def batches(rows, limit):
    """Batches a LIMIT loop fills: the full ones, plus one short one if rows remain."""
    full, rest = divmod(rows, limit)
    return full + (1 if rest else 0), rest


n_batches, rest = batches(ROWS, LIMIT)
per_batch = f"{LIMIT:,}" if not rest else f"{LIMIT:,}, last {rest:,}"
ways = [
    ("row by row, one INSERT per row", ROWS, "1"),
    ("BULK COLLECT LIMIT + FORALL", n_batches, per_batch),
    ("one INSERT ... SELECT", 1, f"{ROWS:,}"),
]

print(f"Copy {ROWS:,} rows three ways (LIMIT {LIMIT:,})")
print()
print(f"{'way':<32}{'INSERT calls':>13}{'rows per call':>18}")
for name, calls, rows_per_call in ways:
    print(f"{name:<32}{calls:>13,}{rows_per_call:>18}")
print()
print(f"FORALL way: also {n_batches + 1:,} FETCH calls, {n_batches:,} that fill a batch")
print("and one that returns no row and ends the loop.")
print("INSERT ... SELECT: no FETCH from PL/SQL; the SQL engine reads")
print(f"the {ROWS:,} source rows inside the one statement.")
print(f"Calls saved by FORALL against row by row: {ROWS - n_batches:,} INSERTs")

Output

Copy 200,000 rows three ways (LIMIT 1,000)

way                              INSERT calls     rows per call
row by row, one INSERT per row        200,000                 1
BULK COLLECT LIMIT + FORALL               200             1,000
one INSERT ... SELECT                       1           200,000

FORALL way: also 201 FETCH calls, 200 that fill a batch
and one that returns no row and ends the loop.
INSERT ... SELECT: no FETCH from PL/SQL; the SQL engine reads
the 200,000 source rows inside the one statement.
Calls saved by FORALL against row by row: 199,800 INSERTs

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

The lab script makes the same copies and prints the elapsed hundredths of a second of each from DBMS_UTILITY.GET_TIME, to compare on your own machine:

three_ways.sql: 200,000 rows copied row by row, in batches and in one statement SQL · load/three_ways.sql
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- One copy of 200,000 generated feed rows, done three ways: row by row (a cursor FOR loop with one INSERT per row),
-- in batches of 1,000 (FETCH ... BULK COLLECT ... LIMIT, then one FORALL per batch) and as one INSERT ... SELECT.
-- Each copy prints its elapsed time in hundredths of a second from DBMS_UTILITY.GET_TIME. Times depend on the
-- machine, so compare the three on yours; three_ways_model.py counts the calls each way sends, which do not change.
-- It needs none of the practice tables and drops the four tables it makes.
SET SERVEROUTPUT ON

DROP TABLE IF EXISTS feed_source PURGE;
DROP TABLE IF EXISTS copy_row PURGE;
DROP TABLE IF EXISTS copy_bulk PURGE;
DROP TABLE IF EXISTS copy_set PURGE;

CREATE TABLE feed_source (
  line_no    NUMBER(10)    NOT NULL,
  account_id NUMBER(10)    NOT NULL,
  amount     NUMBER(14, 2) NOT NULL,
  memo       VARCHAR2(60)
);

-- 200,000 rows with values worked out from LEVEL: accounts 1 to 4 in turn, amounts from 1.00 to 100.98.
INSERT INTO feed_source (line_no, account_id, amount, memo)
SELECT LEVEL, MOD(LEVEL - 1, 4) + 1, MOD(LEVEL * 37, 9999) / 100 + 1, 'generated line ' || LEVEL
FROM dual
CONNECT BY LEVEL <= 200000;
COMMIT;

CREATE TABLE copy_row  AS SELECT * FROM feed_source WHERE 1 = 0;
CREATE TABLE copy_bulk AS SELECT * FROM feed_source WHERE 1 = 0;
CREATE TABLE copy_set  AS SELECT * FROM feed_source WHERE 1 = 0;

DECLARE
  CURSOR c_feed IS
    SELECT line_no, account_id, amount, memo FROM feed_source ORDER BY line_no;
  TYPE feed_tab IS TABLE OF c_feed%ROWTYPE;
  v_rows feed_tab;
  t0     NUMBER;
BEGIN
  -- 1. Row by row: 200,000 INSERT statements, one per pass of the loop.
  t0 := DBMS_UTILITY.GET_TIME;
  FOR r IN c_feed LOOP
    INSERT INTO copy_row (line_no, account_id, amount, memo)
    VALUES (r.line_no, r.account_id, r.amount, r.memo);
  END LOOP;
  DBMS_OUTPUT.PUT_LINE('row by row:            ' || (DBMS_UTILITY.GET_TIME - t0) || ' hundredths');

  -- 2. In batches: one FETCH of up to 1,000 rows, then one FORALL that sends the INSERT with all their values.
  t0 := DBMS_UTILITY.GET_TIME;
  OPEN c_feed;
  LOOP
    FETCH c_feed BULK COLLECT INTO v_rows LIMIT 1000;
    EXIT WHEN v_rows.COUNT = 0;   -- the 201st FETCH returns no row and ends the loop
    FORALL i IN 1 .. v_rows.COUNT
      INSERT INTO copy_bulk (line_no, account_id, amount, memo)
      VALUES (v_rows(i).line_no, v_rows(i).account_id, v_rows(i).amount, v_rows(i).memo);
  END LOOP;
  CLOSE c_feed;
  DBMS_OUTPUT.PUT_LINE('BULK COLLECT + FORALL: ' || (DBMS_UTILITY.GET_TIME - t0) || ' hundredths');

  -- 3. One statement: the SQL engine reads and writes every row; PL/SQL holds none of them.
  t0 := DBMS_UTILITY.GET_TIME;
  INSERT INTO copy_set (line_no, account_id, amount, memo)
  SELECT line_no, account_id, amount, memo FROM feed_source;
  DBMS_OUTPUT.PUT_LINE('INSERT ... SELECT:     ' || (DBMS_UTILITY.GET_TIME - t0) || ' hundredths');
  COMMIT;
END;
/
-- Expected: three lines, each with the elapsed hundredths of one copy on this machine.

SELECT (SELECT COUNT(*) FROM copy_row)  AS row_by_row,
       (SELECT COUNT(*) FROM copy_bulk) AS bulk_forall,
       (SELECT COUNT(*) FROM copy_set)  AS set_based
FROM dual;
-- Expected: 200000 in each column: the three ways copied the same rows.

DROP TABLE copy_set PURGE;
DROP TABLE copy_bulk PURGE;
DROP TABLE copy_row PURGE;
DROP TABLE feed_source PURGE;
three_ways.sql, run in the lab Python · three_ways_session.py
"""three_ways.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("load/three_ways.sql")

Output

$ sqlplus -s lab@//localhost/FREEPDB1 @load/three_ways.sql
Table dropped.


Table dropped.


Table dropped.


Table dropped.


Table created.


200000 rows created.


Commit complete.


Table created.


Table created.


Table created.

row by row:            354 hundredths
BULK COLLECT + FORALL: 31 hundredths
INSERT ... SELECT:     24 hundredths

PL/SQL procedure successfully completed.


ROW_BY_ROW BULK_FORALL  SET_BASED
---------- ----------- ----------
    200000      200000     200000


Table dropped.


Table dropped.


Table dropped.


Table dropped.

This output changes from run to run: the times depend on the computer and change from run to run

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

When SQL can compute every column, the one INSERT … SELECT wins: 1 call against 200, and no row travels to PL/SQL and back, so set-based: Code that describes a whole set of rows in one statement, such as one UPDATE or one query, instead of a loop that handles one row or one value per pass. More in “T-SQL control flow: IF, WHILE and CASE” code comes first. FORALL earns its place when each row needs PL/SQL before it is written, such as a lookup, a calculation in several steps or a procedure call: the batch is worked on in PL/SQL and goes back in one call.

Checkpoint

Check: One SQL statement first, FORALL for per-row logic

  1. Question 1 of 2 A job copies 50,000 rows with FETCH ... BULK COLLECT INTO ... LIMIT 500 and one FORALL INSERT per batch. How many INSERT calls does it send?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 100 calls

    50,000 rows in batches of 500 make 100 full batches, and each batch is one FORALL, so one INSERT call each. The loop also makes 101 FETCH calls, 100 that fill a batch and one that returns no row and ends the loop.

  2. Question 2 of 2 Each staged order needs a discount worked out by a PL/SQL procedure of several steps before it is inserted. Which load fits best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: BULK COLLECT ... LIMIT, the procedure called for each row of the batch, then one FORALL INSERT per batch

    Plain SQL comes first when it can compute every column. When it cannot, work on a batch in PL/SQL and send it back with FORALL.

One failing element stops the FORALL

The feed feed-batch-17 holds 10 ledger entries, and three break the ledger’s rules: line 4 has amount 0.00, which ledger_amt_nonzero refuses, line 7 names account 9, which does not exist, and line 9 has no posted_at. Without SAVE EXCEPTIONS, FORALL stops at the first element that fails, and Oracle’s bulk SQL page gives two endings. When nothing handles the exception, PL/SQL rolls back the rows of the earlier elements too. When a handler catches it, only the failing element is undone: the rows before it stay in the transaction: A group of changes that the database keeps or undoes as one unit: a COMMIT makes all of them permanent, and a ROLLBACK removes all of them. More in “T-SQL transactions, @@TRANCOUNT and savepoints”, and the script’s handler counts them with SQL%ROWCOUNT. The elements after it never run. Press Edit, change FEED and run the model:

feed-batch-17 through a FORALL that stops at its first failure Python · model/first_failure_model.py
"""The feed of first_failure.sql through a FORALL without SAVE EXCEPTIONS.

What it models: Oracle's rules for a FORALL statement whose DML fails on one
element (the bulk SQL page: unhandled exceptions, and handling FORALL
exceptions immediately). The SQL engine runs the INSERT for elements 1, 2,
3 ... in order and stops at the first entry the ledger refuses. When the
block handles the exception, only that element's change is rolled back:
the rows of the elements before it stay, and SQL%ROWCOUNT counts them. When
nothing handles it, PL/SQL rolls back every row the FORALL inserted. The
elements after the failing one never run in either case.
The ledger's rules: amount <> 0 (ledger_amt_nonzero), an account that
exists (ledger_account_fk; accounts 1 to 4) and a posted_at (NOT NULL).
Each bad entry of this feed breaks one rule only.
What to edit: FEED (fix line 4's amount, or break another line).
"""
from decimal import Decimal

FEED = [  # line, account_id, amount, has a posted_at
    (1, 1, "1200.00", True),
    (2, 3, "-150.00", True),
    (3, 2, "4800.00", True),
    (4, 1, "0.00", True),
    (5, 4, "-320.00", True),
    (6, 1, "-75.50", True),
    (7, 9, "600.00", True),
    (8, 3, "210.00", True),
    (9, 4, "95.00", False),
    (10, 1, "40.00", True),
]
ACCOUNTS = {1, 2, 3, 4}


def refusal(account, amount, dated):
    """The error the ledger raises for one entry, or None when it is accepted."""
    if not dated:
        return "ORA-01400"  # posted_at is NOT NULL
    if Decimal(amount) == 0:
        return "ORA-02290"  # check constraint ledger_amt_nonzero
    if account not in ACCOUNTS:
        return "ORA-02291"  # foreign key ledger_account_fk
    return None


print(f"FORALL ... INSERT of feed-batch-17: {len(FEED)} entries,")
print("no SAVE EXCEPTIONS")
print()
print(f"{'line':>4}{'account':>9}{'amount':>10}  {'posted_at':<10} what happens")
stopped_at = None
inserted = 0
for line, account, amount, dated in FEED:
    if stopped_at is not None:
        result = "never run"
    else:
        error = refusal(account, amount, dated)
        if error:
            stopped_at = line
            result = f"{error}: the FORALL stops"
        else:
            inserted += 1
            result = "inserted"
    date = "set" if dated else "NULL"
    print(f"{line:>4}{account:>9}{amount:>10}  {date:<10} {result}")

bad = [line for line, a, amt, d in FEED if refusal(a, amt, d)]
print()
if stopped_at is None:
    print(f"No entry refused: {inserted} rows inserted, SQL%ROWCOUNT = {inserted}")
else:
    print(f"With a handler:    {inserted} rows stay, SQL%ROWCOUNT = {inserted}")
    print(f"Without a handler: PL/SQL rolls back those {inserted}, 0 stay")
    unseen = [line for line in bad if line != stopped_at]
    print(f"Bad lines in the feed: {', '.join(map(str, bad))};", end=" ")
    print(f"this run reports line {stopped_at} only")
    if unseen:
        print(f"Lines never checked: {', '.join(map(str, unseen))}, until the next run")

Output

FORALL ... INSERT of feed-batch-17: 10 entries,
no SAVE EXCEPTIONS

line  account    amount  posted_at  what happens
   1        1   1200.00  set        inserted
   2        3   -150.00  set        inserted
   3        2   4800.00  set        inserted
   4        1      0.00  set        ORA-02290: the FORALL stops
   5        4   -320.00  set        never run
   6        1    -75.50  set        never run
   7        9    600.00  set        never run
   8        3    210.00  set        never run
   9        4     95.00  NULL       never run
  10        1     40.00  set        never run

With a handler:    3 rows stay, SQL%ROWCOUNT = 3
Without a handler: PL/SQL rolls back those 3, 0 stay
Bad lines in the feed: 4, 7, 9; this run reports line 4 only
Lines never checked: 7, 9, until the next run

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

The script runs both endings on the practice schema:

first_failure.sql: the same FORALL without a handler and with one SQL · errors/first_failure.sql
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- What FORALL does by default when one element fails. The script stages the feed feed-batch-17, 10 ledger entries of
-- which 3 break the ledger's rules (line 4: amount 0, ledger_amt_nonzero; line 7: account 9, ledger_account_fk;
-- line 9: no posted_at, NOT NULL), and loads it with one FORALL twice: without a handler, then with one.
-- Run schema_oracle.sql of practice-schema-and-dialect-basics first; first_failure_model.py prints the same counts.
-- The ROLLBACK and the DROP at the end leave the practice data as they found it.
SET SERVEROUTPUT ON

DROP TABLE IF EXISTS ledger_feed PURGE;

-- The staging table has no constraints, so the bad lines can land here and fail only on their way into the ledger.
CREATE TABLE ledger_feed (
  line_no    NUMBER(4),
  account_id NUMBER(10),
  amount     NUMBER(14, 2),
  posted_at  DATE,
  memo       VARCHAR2(60)
);

INSERT INTO ledger_feed VALUES (1, 1, 1200.00, DATE '2026-04-02', 'feed-batch-17 line 01');
INSERT INTO ledger_feed VALUES (2, 3, -150.00, DATE '2026-04-02', 'feed-batch-17 line 02');
INSERT INTO ledger_feed VALUES (3, 2, 4800.00, DATE '2026-04-02', 'feed-batch-17 line 03');
-- bad: amount 0
INSERT INTO ledger_feed VALUES (4, 1, 0.00, DATE '2026-04-02', 'feed-batch-17 line 04');
INSERT INTO ledger_feed VALUES (5, 4, -320.00, DATE '2026-04-02', 'feed-batch-17 line 05');
INSERT INTO ledger_feed VALUES (6, 1, -75.50, DATE '2026-04-02', 'feed-batch-17 line 06');
-- bad: account 9
INSERT INTO ledger_feed VALUES (7, 9, 600.00, DATE '2026-04-02', 'feed-batch-17 line 07');
INSERT INTO ledger_feed VALUES (8, 3, 210.00, DATE '2026-04-02', 'feed-batch-17 line 08');
-- bad: no date
INSERT INTO ledger_feed VALUES (9, 4, 95.00, NULL, 'feed-batch-17 line 09');
INSERT INTO ledger_feed VALUES (10, 1, 40.00, DATE '2026-04-02', 'feed-batch-17 line 10');
-- the staged lines stay; every change to the ledger below is rolled back at the end
COMMIT;

SELECT COUNT(*) AS ledger_rows FROM ledger;
-- Expected: 5

-- Load 1: FORALL with no handler.
DECLARE
  TYPE feed_tab IS TABLE OF ledger_feed%ROWTYPE;
  v_feed feed_tab;
BEGIN
  SELECT * BULK COLLECT INTO v_feed FROM ledger_feed ORDER BY line_no;
  FORALL i IN 1 .. v_feed.COUNT
    INSERT INTO ledger (account_id, amount, posted_at, memo)
    VALUES (v_feed(i).account_id, v_feed(i).amount, v_feed(i).posted_at, v_feed(i).memo);
END;
/
-- Expected: ORA-02290: check constraint (LAB.LEDGER_AMT_NONZERO) violated, raised by element 4

SELECT COUNT(*) AS ledger_rows FROM ledger;
-- Expected: 5. The exception was not handled, so PL/SQL rolled back the rows of elements 1 to 3 as well (Bulk SQL
-- page, "Unhandled Exceptions in FORALL Statements"), and elements 5 to 10 never ran.

-- Load 2: the same FORALL, with a handler. It only reports, to show what the handler sees; a real load would roll
-- back or raise the error again after logging it, so that its caller sees the failure.
DECLARE
  TYPE feed_tab IS TABLE OF ledger_feed%ROWTYPE;
  v_feed feed_tab;
  v_kept PLS_INTEGER;
BEGIN
  SELECT * BULK COLLECT INTO v_feed FROM ledger_feed ORDER BY line_no;
  FORALL i IN 1 .. v_feed.COUNT
    INSERT INTO ledger (account_id, amount, posted_at, memo)
    VALUES (v_feed(i).account_id, v_feed(i).amount, v_feed(i).posted_at, v_feed(i).memo);
EXCEPTION
  WHEN OTHERS THEN
    v_kept := SQL%ROWCOUNT;   -- copy it first: the next SQL statement would replace it
    DBMS_OUTPUT.PUT_LINE('stopped: ' || SQLERRM);
    DBMS_OUTPUT.PUT_LINE('rows kept: ' || v_kept);
END;
/
-- Expected:
-- stopped: ORA-02290: check constraint (LAB.LEDGER_AMT_NONZERO) violated
-- rows kept: 3

SELECT COUNT(*) AS ledger_rows FROM ledger;
-- Expected: 8. The handled error rolled back element 4 only; the rows of elements 1 to 3 wait in the open
-- transaction (Bulk SQL page, "Handling FORALL Exceptions Immediately"), and elements 5 to 10 never ran.

ROLLBACK;
DROP TABLE ledger_feed PURGE;
SELECT COUNT(*) AS ledger_rows FROM ledger;
-- Expected: 5
first_failure.sql, run in the lab Python · first_failure_session.py
"""first_failure.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("errors/first_failure.sql", errors=1)

Output

$ sqlplus -s lab@//localhost/FREEPDB1 @errors/first_failure.sql
Table dropped.


Table created.


1 row created.


1 row created.


1 row created.


1 row created.


1 row created.


1 row created.


1 row created.


1 row created.


1 row created.


1 row created.


Commit complete.


LEDGER_ROWS
-----------
          5

DECLARE
*
ERROR at line 1:
ORA-02290: check constraint (LAB.LEDGER_AMT_NONZERO) violated
ORA-06512: at line 6
Help: https://docs.oracle.com/error-help/db/ora-02290/



LEDGER_ROWS
-----------
          5

stopped: ORA-02290: check constraint (LAB.LEDGER_AMT_NONZERO) violated
rows kept: 3

PL/SQL procedure successfully completed.


LEDGER_ROWS
-----------
          8


Rollback complete.


Table dropped.


LEDGER_ROWS
-----------
          5

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

With a handler 3 rows stay and without one none, and lines 7 and 9 are never tried.

Key idea

A load that stops at its first error finds one bad line per run.

SAVE EXCEPTIONS: A clause of the FORALL statement that lets it carry on past elements whose DML fails, instead of stopping at the first one, so that the failures are dealt with once the statement ends. is a clause of FORALL that lets it carry on past failing elements and deal with all the failures once it ends. DML error logging: A LOG ERRORS clause on one INSERT, UPDATE, MERGE or DELETE: each row the statement refuses is written to an error logging table, and the statement can carry on. is a LOG ERRORS clause on one INSERT, UPDATE, MERGE or DELETE: each refused row goes to an error logging table: The table into which DML error logging writes a row for each row that a statement refuses., and the statement can carry on. With FORALL, SQL%BULK_EXCEPTIONS: An implicit cursor attribute that describes the elements of a FORALL statement whose DML failed. describes the elements that failed, and SQL%BULK_ROWCOUNT: An implicit cursor attribute that holds, for each element of a FORALL statement, the number of rows that element's DML statement changed. counts the rows each element changed.

Checkpoint

Check: What one failing element does to a FORALL

  1. Question 1 of 3 A FORALL INSERT runs over 8 elements, and element 6 breaks a check constraint. The block's handler catches the error and prints SQL%ROWCOUNT. What does it print?

    Type a number.

    Show the answer: Question 1 of 3

    Answer: 5 rows

    Without SAVE EXCEPTIONS the FORALL stops at element 6. The handled error undoes element 6 only, so the 5 rows of elements 1 to 5 stay and SQL%ROWCOUNT counts them. Elements 7 and 8 never run.

  2. Question 2 of 3 The same FORALL over 8 elements fails at element 6 in a block with no EXCEPTION section. What is left of its rows?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: None, because PL/SQL rolls back elements 1 to 5 too, and elements 7 and 8 never run

    Handled, the earlier rows stay; unhandled, none of them do. Either way the elements after the failing one never run.

  3. Question 3 of 3 What does adding SAVE EXCEPTIONS to a FORALL change?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: The FORALL carries on past failing elements instead of stopping at the first one

    SAVE EXCEPTIONS is a clause of FORALL, and DML error logging is a clause of one SQL statement. Both carry a load past bad rows.

SAVE EXCEPTIONS and row counts per element, worked on the feed Pro: opens with a Pro, Premium or Ultimate pass

SAVE EXCEPTIONS turns the stop at the first bad element into a list of every failure, which the block reads once the statement has finished. Below, feed-batch-17 is loaded with it, its failures become a reject report a person can act on, and the rows that each element of a FORALL changed are counted.

In this part:

  • Seven rows in and one exception at the end
  • A reject report sorted by element, with the values that failed
  • Rows changed by each element, zero included

1 diagram · 3 runnable examples (Python, SQL) · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

DML error logging, what it cannot log, and choosing a load design Pro: opens with a Pro, Premium or Ultimate pass

DML error logging moves the reject report into the database: one INSERT … SELECT writes each refused row to a table and carries on. Below, the table is created, the feed is loaded at several reject limits, the failures the clause cannot log are listed, and every load design of the lesson is set side by side.

In this part:

  • An error table for the ledger in one call
  • One INSERT that keeps the good rows and logs the bad
  • What DML error logging cannot catch
  • Choosing a load design for a feed with bad rows

3 runnable examples (Python, SQL) · about 13 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Practise: bounds, batches and the first failure

Each question gives a statement, a collection or a short feed: decide what runs and what stays, then read the reasons:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 12 What does a FORALL statement send to the SQL engine?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: One DML statement with the values of every element in its bounds

    FORALL binds whole collections to one DML statement, so a batch costs one call.

  2. Question 2 of 12 v_feed has 10 elements, and v_pick holds the numbers 2, 5 and 9. Which elements of v_feed does FORALL i IN VALUES OF v_pick insert, with v_feed(i) in its VALUES?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Elements 2, 5 and 9

    VALUES OF suits a subset of a collection. The pointer collection names the elements, and no copy of the data is needed.

  3. Question 3 of 12 A FORALL UPDATE reads WHERE account_id = v_ids(i + 1). What happens?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Oracle refuses to compile it, since a FORALL index may serve only as a subscript and never inside an expression

    Prepare a collection whose element i already holds the value you need, and use it as v_next(i).

  4. Question 4 of 12 A nested table of 6 elements has element 4 deleted. How many times does FORALL i IN INDICES OF v_rows run its statement?

    Type a number.

    Show the answer: Question 4 of 12

    Answer: 5 times

    INDICES OF runs once for each element that exists, 1, 2, 3, 5 and 6. A range from 1 to 6 would raise an exception at the missing element 4.

  5. Question 5 of 12 A cursor FOR loop with one INSERT inside copies 200,000 rows. How many INSERT statements does it send to the SQL engine?

    Type a number.

    Show the answer: Question 5 of 12

    Answer: 200000 statements

    One per pass of the loop. A FORALL per batch of 1,000 sends 200 INSERT calls, and one INSERT ... SELECT sends 1.

  6. Question 6 of 12 The rows of a staging table need only a CASE expression and a join before they go into the ledger. Which way fits?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: One INSERT ... SELECT

    Set-based SQL comes first, and PL/SQL only when a row needs logic that SQL cannot express.

  7. Question 7 of 12 A LIMIT loop reads 30,000 rows in batches of 2,000 and ends on the first FETCH that returns no row. How many FETCH calls does it make?

    Type a number.

    Show the answer: Question 7 of 12

    Answer: 16 calls

    15 FETCH calls fill a batch of 2,000 each, and a 16th returns no row and ends the loop.

  8. Question 8 of 12 The SQL engine still runs a FORALL's INSERT once per element. Why is a FORALL usually much faster than a FOR loop with the same INSERT?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: The values of a whole batch travel in one call, so the trips between PL/SQL and SQL fall from one per row to one per batch

    A FOR loop pays one call for every row, and a FORALL pays one for every batch.

  9. Question 9 of 12 Without SAVE EXCEPTIONS, where does a FORALL stop?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: At the first element whose DML fails

    The elements before the failing one have run, and the elements after it never do.

  10. Question 10 of 12 A FORALL INSERT of 12 elements fails at element 10 in a block whose handler reads SQL%ROWCOUNT. What does the handler read?

    Type a number.

    Show the answer: Question 10 of 12

    Answer: 9 rows

    Elements 1 to 9 inserted one row each, and the handled error undoes element 10 only. Elements 11 and 12 never run.

  11. Question 11 of 12 A block runs a FORALL INSERT over 6 elements without an EXCEPTION section, and element 5 fails. How many of its rows stay in the table?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: None

    A handler keeps the rows of the elements before the failing one; without a handler none of them stay.

  12. Question 12 of 12 A nightly feed has bad lines at 3, 40 and 75. A FORALL without SAVE EXCEPTIONS loads it, and a handler logs the error. What does one night's log show?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Only the error of line 3, because lines 40 and 75 are never tried and come to light one per run

    A load that must report every bad line needs a way past the first failure, such as SAVE EXCEPTIONS or DML error logging.

Interview questions

Warm-up (fresher to 2–5 years): how many statements can one FORALL hold, and where may its index appear?

One INSERT, UPDATE, DELETE or MERGE, static or dynamic, that reads at least one collection; a second statement needs a second FORALL over the same collections. The FORALL declares its own index, which lives only inside it and may appear only as a subscript, as in v_ids(i): never in an expression such as i + 1, and never assigned.

Three longer questions follow: a price load left half done, an address job that logged no errors while changes went missing, and a fast load that met duplicate keys. Settle your first move before opening each:

  1. Mid to senior

    A nightly job applies 3,000 price changes to the stock table: a LIMIT 500 loop with one FORALL UPDATE per batch, and one handler at the block's end that logs SQLERRM and SQL%ROWCOUNT, then commits. Element 120 of batch 3 sets a price of 0. Which prices changed, and how would you redesign the job?

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

  2. Mid to senior

    A job updates customers' cities from a CRM feed of 2,000 lines with FORALL ... SAVE EXCEPTIONS and UPDATE customers SET city = v_city(i) WHERE email = v_email(i). It logs no errors, yet 40 customers still show their old city. Find why, and change the job so that it reports such lines.

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    For speed, a team adds the APPEND hint to its nightly load of 2 million order lines, an INSERT ... SELECT ... LOG ERRORS INTO order_line_errs REJECT LIMIT UNLIMITED. The next night a feed whose only fault is 12 repeated line ids fails with ORA-00001, and nothing is loaded or logged. Explain why, and redesign the load.

    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 is FORALL, despite looking like a loop?

    One statement: it binds collections to a single INSERT, UPDATE, DELETE or MERGE and sends it to the SQL engine in one call, which runs it once for each index in the bounds. It is the sending half of bulk SQL, where BULK COLLECT is the fetching half.

  2. What are FORALL's three ways of picking elements?

    A range such as 1 .. v_feed.COUNT, which needs every index of the range, so a deleted element raises an exception; INDICES OF, which takes only the elements that exist, for a collection with gaps; and VALUES OF, which runs the elements whose numbers another collection holds.

  3. Which limits does Oracle set on a FORALL?

    One DML statement per FORALL, the index only as a subscript and never inside an expression, and the same indexes in every collection the statement reads.

  4. Which form comes first, and when does FORALL earn its place?

    When SQL can compute every column, one INSERT ... SELECT wins: in the lesson's copy of 200,000 rows it is 1 call against 200 for batches, and no row travels to PL/SQL and back. FORALL is for rows that need PL/SQL first: a lookup, a calculation in several steps or a procedure call.

  5. What happens to a FORALL without SAVE EXCEPTIONS when one element fails?

    It stops at that element, and the elements after it never run. With no handler, PL/SQL rolls back the rows of the earlier elements too; with a handler, only the failing element is undone and the rows before it stay in the transaction, counted by SQL%ROWCOUNT.

  6. What does the SAVE EXCEPTIONS clause change?

    It lets the FORALL carry on past failing elements and deal with all the failures once it ends, so one run finds every bad line instead of the first.

  7. Which two attributes report on a bulk DML statement?

    SQL%BULK_EXCEPTIONS describes the elements that failed, and SQL%BULK_ROWCOUNT counts the rows each element changed.

  8. What does DML error logging do for a single SQL statement?

    A LOG ERRORS clause on one INSERT, UPDATE, MERGE or DELETE sends each refused row to an error logging table, so the statement can carry on instead of stopping.

Key takeaways

  • FORALL sends one DML statement with the values of a whole batch in one call, and its bounds pick the elements: a range, INDICES OF for gaps, or VALUES OF for chosen elements.
  • Write one INSERT … SELECT when SQL can compute every column, and FORALL when each row needs PL/SQL first.
  • Without SAVE EXCEPTIONS a FORALL stops at its first failing element: a handler keeps the earlier rows, and without one no row stays.
  • SAVE EXCEPTIONS carries a FORALL past failing elements, and DML error logging carries one SQL statement past refused rows.

Terms in this lesson

DML error logging
A LOG ERRORS clause on one INSERT, UPDATE, MERGE or DELETE: each row the statement refuses is written to an error logging table, and the statement can carry on.
error logging table
The table into which DML error logging writes a row for each row that a statement refuses.
FORALL statement
A PL/SQL statement that sends one INSERT, UPDATE, DELETE or MERGE to the SQL engine in a single call, with the values of a whole collection bound to it; the engine runs it once for each index in its bounds.
SAVE EXCEPTIONS
A clause of the FORALL statement that lets it carry on past elements whose DML fails, instead of stopping at the first one, so that the failures are dealt with once the statement ends.
SQL%BULK_EXCEPTIONS
An implicit cursor attribute that describes the elements of a FORALL statement whose DML failed.
SQL%BULK_ROWCOUNT
An implicit cursor attribute that holds, for each element of a FORALL statement, the number of rows that element's DML statement changed.

References

Related tools

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.