Chapter 11 · PL/SQL Fundamentals: Blocks, Types, Procedures, Functions, and Packages

Cursors, Bulk Collect, FORALL, Context Switches, and High-Performance Data Processing

Compare row-by-row cursors, BULK COLLECT, FORALL, and set-based SQL using PL/SQL-to-SQL boundary and PGA evidence, with LIMIT and SAVE EXCEPTIONS for bounded bulk processing.

Advanced120–145 minutesSet SQL vs row/bulk processing labBULK COLLECT · LIMIT · FORALL · SAVE EXCEPTIONSPGA/session statistics observed, not prescribedLast reviewed: August 2026

Learning outcomes

ServiceHub receives a large batch of status changes. A PL/SQL routine fetches one row, validates it, updates one row, and repeats. The SQL is simple, but the program repeatedly crosses the PL/SQL/SQL boundary and can allocate excessive session memory if “optimized” with one unbounded BULK COLLECT. The right progression is set-based SQL first, bounded bulk processing second, and row-by-row only when procedural semantics require it.

01

Distinguish implicit cursors, explicit cursors, and cursor FOR loops.

02

Explain why BULK COLLECT and FORALL reduce PL/SQL-to-SQL communication compared with row-by-row SQL.

03

Use FETCH ... BULK COLLECT ... LIMIT to bound collection and PGA growth.

04

Use FORALL with SAVE EXCEPTIONS and SQL%BULK_EXCEPTIONS when partial batch failures must be reported.

05

Measure session PGA/logical-work evidence before and after rather than relying on universal batch sizes.

Version, scope, privileges, tooling, and licensing baseline

Mandatory examples target a disposable ServiceHub schema in Oracle AI Database Free 26ai and were reviewed against RU 23.26.3, SQL Developer 26.2, and SQLcl 26.2.1. Free is limited to 2 foreground CPU cores, 2 GB combined SGA/PGA memory, and 12 GB user data and receives no Oracle patches or Support service requests. The chapter requires no Diagnostics Pack, Tuning Pack, RAC, Data Guard, Exadata, GoldenGate, OCI service, or COMPATIBLE change. Stored program creation in the course owner requires CREATE PROCEDURE; cross-schema execution requires explicit EXECUTE grants. Dynamic performance views used for optional session/PGA evidence should be read through a DBA observer or narrowly granted V_$ views in the disposable PDB.

1. Cursor choices start with result-shape needs

PL/SQL implicitly creates a cursor for every SQL statement. SQL%ROWCOUNT and related attributes describe the most recent implicit SQL cursor. An explicit cursor gives control over a multirow query's open/fetch/close lifecycle. A cursor FOR loop is simpler when you just need sequential records because PL/SQL manages that lifecycle.

sql · cursor FOR loop
BEGIN  FOR r IN (    SELECT case_id, amount    FROM servicehub_bulk_case    WHERE status_code='OPEN'    ORDER BY case_id  ) LOOP    -- Procedural per-row work would happen here.    NULL;  END LOOP;END;/

If the loop merely executes one DML statement for each row, ask first whether one set-based DML statement can express the operation.

2. Row-by-row PL/SQL repeatedly crosses the engine boundary

Oracle documents bulk SQL as a way to reduce communication between the PL/SQL engine and SQL engine. BULK COLLECT returns multiple rows into collections in one operation; FORALL sends many DML bind values to SQL in batches rather than issuing one SQL statement per PL/SQL iteration.

sql · row-by-row baseline
BEGIN  FOR r IN (    SELECT case_id    FROM servicehub_bulk_case    WHERE status_code='OPEN'  ) LOOP    UPDATE servicehub_bulk_case    SET amount = amount + 1    WHERE case_id = r.case_id;  END LOOP;END;/ROLLBACK;
sql · best baseline when one SQL statement expresses the transformation
UPDATE servicehub_bulk_caseSET amount = amount + 1WHERE status_code='OPEN';ROLLBACK;

Bulk PL/SQL is not more set-based than SQL. It is a procedural optimization for cases where the program truly needs collections or per-item logic around SQL.

3. BULK COLLECT without a bound can consume large session memory

Collections consume session/process memory. In dedicated-server configurations this is normally reflected in the process/session PGA; shared-server placement differs because the user global area can move to shared memory. Fetching an unbounded large result into a collection can therefore create avoidable memory pressure.

sql · observe current and peak session PGA
SELECT n.name, s.valueFROM v$mystat sJOIN v$statname n ON n.statistic#=s.statistic#WHERE n.name IN ('session pga memory','session pga memory max')ORDER BY n.name;
sql · bounded fetch pattern
DECLARE  CURSOR c_cases IS    SELECT case_id, amount    FROM servicehub_bulk_case    WHERE status_code='OPEN'    ORDER BY case_id;  TYPE t_case_tab IS TABLE OF c_cases%ROWTYPE;  l_cases t_case_tab;BEGIN  OPEN c_cases;  LOOP    FETCH c_cases BULK COLLECT INTO l_cases LIMIT 500;    EXIT WHEN l_cases.COUNT = 0;    DBMS_OUTPUT.PUT_LINE('batch rows=' || l_cases.COUNT);  END LOOP;  CLOSE c_cases;END;/

500 is a lab value, not a production recommendation. Choose a batch size by measuring memory, latency, row width, error handling, and throughput on the actual deployment.

4. FORALL batches DML bind values

sql · bulk update
DECLARE  TYPE t_id_tab IS TABLE OF NUMBER;  l_ids t_id_tab;BEGIN  SELECT case_id  BULK COLLECT INTO l_ids  FROM servicehub_bulk_case  WHERE status_code='OPEN'  FETCH FIRST 1000 ROWS ONLY;  FORALL i IN 1..l_ids.COUNT    UPDATE servicehub_bulk_case    SET amount = amount + 1    WHERE case_id = l_ids(i);  DBMS_OUTPUT.PUT_LINE('total rows=' || SQL%ROWCOUNT);END;/ROLLBACK;

FORALL is not a general-purpose loop. Its body is one DML statement executed with collection-supplied values. Oracle's current documentation also notes that bulk SQL disables parallel DML, so do not combine bulk PL/SQL and PDML assumptions casually.

5. SAVE EXCEPTIONS changes batch error semantics

Without SAVE EXCEPTIONS, an unhandled failure stops FORALL. With SAVE EXCEPTIONS, PL/SQL records individual failures, continues eligible iterations, then raises ORA-24381 after the batch. Inspect SQL%BULK_EXCEPTIONS to map errors back to collection indexes.

sql · controlled partial failure
DECLARE  TYPE t_id_tab IS TABLE OF NUMBER;  TYPE t_status_tab IS TABLE OF VARCHAR2(30);  l_ids      t_id_tab     := t_id_tab(1,2,3);  l_statuses t_status_tab := t_status_tab(    'OPEN',    'THIS_STATUS_IS_TOO_LONG',    'CLOSED'  );  e_bulk_errors EXCEPTION;  PRAGMA EXCEPTION_INIT(e_bulk_errors,-24381);BEGIN  FORALL i IN 1..l_ids.COUNT SAVE EXCEPTIONS    UPDATE servicehub_bulk_case    SET status_code = l_statuses(i)    WHERE case_id = l_ids(i);EXCEPTION  WHEN e_bulk_errors THEN    FOR j IN 1..SQL%BULK_EXCEPTIONS.COUNT LOOP      DBMS_OUTPUT.PUT_LINE(        'item=' || SQL%BULK_EXCEPTIONS(j).ERROR_INDEX ||        ' code=' || SQL%BULK_EXCEPTIONS(j).ERROR_CODE ||        ' message=' ||        SQLERRM(-SQL%BULK_EXCEPTIONS(j).ERROR_CODE)      );    END LOOP;    ROLLBACK;END;/

This is one of the cases where SQLERRM(error_code) remains practical for individual bulk errors even though FORMAT_ERROR_STACK is preferred for normal exception stacks.

6. Deliberately wrong: BULK COLLECT the whole table because “bulk is faster”

Loading millions of wide rows into one collection can inflate PGA/session memory and create a new bottleneck. The repair is to keep SQL set-based where possible; otherwise fetch bounded batches with LIMIT, process them, and reuse collection memory between iterations.

7. Reproducible lab

sql · setup
BEGIN  EXECUTE IMMEDIATE 'DROP TABLE servicehub_bulk_case PURGE';EXCEPTION WHEN OTHERS THEN  IF SQLCODE != -942 THEN RAISE; END IF;END;/CREATE TABLE servicehub_bulk_case (  case_id      NUMBER PRIMARY KEY,  status_code  VARCHAR2(12) NOT NULL,  amount       NUMBER(10,2) NOT NULL,  payload      VARCHAR2(200));INSERT INTO servicehub_bulk_caseSELECT  LEVEL,  CASE MOD(LEVEL,4) WHEN 0 THEN 'OPEN' ELSE 'CLOSED' END,  MOD(LEVEL*37,10000)/10,  RPAD('x',120,'x')FROM dualCONNECT BY LEVEL <= 20000;COMMIT;
sql · capture repeatable session evidence
SELECT n.name, s.valueFROM v$mystat sJOIN v$statname n ON n.statistic#=s.statistic#WHERE n.name IN (  'session pga memory',  'session pga memory max',  'session logical reads',  'execute count')ORDER BY n.name;

Run the set-based UPDATE, row-by-row loop, and bounded bulk variant separately with rollback between tests, taking the same statistic snapshot before and after. Do not publish one lab timing as a universal speed ratio; row width, cache state, storage, and concurrent workload all matter.

sql · cleanup
DROP TABLE servicehub_bulk_case PURGE;

8. Production judgment

Prefer one SQL statement. If procedural data handling is required, use cursor FOR loops for clarity at controlled scale and bulk SQL for repeated SQL boundary crossings. Use LIMIT to bound memory, SQL%BULK_ROWCOUNT to inspect affected rows, and SAVE EXCEPTIONS only when partial batch success is a deliberate business contract.

No special option, pack, restart, or COMPATIBLE change is required. Lesson 5 closes the chapter with exception design: preserve the original error, add stack/backtrace context, log without committing business work accidentally, and make package behavior testable.

Check your understanding

  1. What problem do BULK COLLECT and FORALL primarily reduce?
  2. Why can an unbounded BULK COLLECT be dangerous?
  3. What is the baseline comparator before choosing FORALL?
  4. What happens after FORALL SAVE EXCEPTIONS completes with failures?
  5. Is one fixed LIMIT value appropriate for every workload?
Review the answers

They reduce repeated communication between the PL/SQL engine and SQL engine for multirow work.

It can allocate a large in-memory collection and pressure session/process memory.

A single set-based SQL statement that expresses the same transformation.

PL/SQL raises ORA-24381 and exposes individual failure indexes/codes through SQL%BULK_EXCEPTIONS.

No. Batch size depends on row width, memory, latency, throughput, error handling, and deployment characteristics.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.