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.
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
What you’ll learn · Before you start
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
On this page
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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:
"""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
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 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:
-- 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 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
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:
"""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
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 script runs both endings on the practice schema:
-- 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 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
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
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:
-
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?
-
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.
-
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.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- PL/SQL Language Reference 26ai: Bulk SQL and Bulk Binding (Oracle)
- PL/SQL Language Reference 26ai: FORALL Statement (Oracle)
- PL/SQL Language Reference 26ai: Implicit Cursor Attribute (Oracle)
- PL/SQL Language Reference 26ai: SQLERRM Function (Oracle)
- PL/SQL Language Reference 26ai: Predefined Exceptions (Oracle)
- PL/SQL Language Reference 26ai: EXCEPTION_INIT Pragma (Oracle)
- PL/SQL Language Reference 26ai: Associative Arrays (Oracle)
- PL/SQL Packages and Types Reference 26ai: DBMS_ERRLOG (Oracle)
- PL/SQL Packages and Types Reference 26ai: DBMS_UTILITY (Oracle)
- SQL Language Reference 26ai: INSERT (Oracle)
- SQL Language Reference 26ai: Types of SQL Statements (Oracle)
- Administrator's Guide 26ai: Managing Tables (Oracle)
- Database Error Messages: ORA-24381 (Oracle)
- Database Error Messages: ORA-02290 (Oracle)
- Database Error Messages: ORA-02291 (Oracle)
- Database Error Messages: ORA-01400 (Oracle)
- Database Concepts 26ai: Data Integrity (Oracle)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress