Oracle PL/SQL Bulk Operations: BULK COLLECT and FORALL

Oracle PL/SQL Bulk Operations: BULK COLLECT and FORALL

If you are processing large volumes of data in PL/SQL using row-by-row cursor loops, you are almost certainly doing it the slow way. The difference between row-by-row processing and bulk operations can be enormous — 10x, 50x, even 100x faster — because bulk operations dramatically reduce the number of context switches between the PL/SQL engine and the SQL engine.

In this guide, we cover BULK COLLECT for fast data retrieval and FORALL for fast bulk DML, along with the patterns that make them practical in real production code.

Why Row-by-Row Is Slow

Every time a PL/SQL program executes a SQL statement, there is a context switch between two Oracle engines: the PL/SQL engine and the SQL engine. For a loop that processes 10,000 rows one at a time, this means 10,000 context switches for the FETCH and another 10,000 for each DML operation — 20,000+ round trips.

Bulk operations handle this in a single batch, dramatically reducing the overhead.

BULK COLLECT: Fetching Multiple Rows at Once

BULK COLLECT INTO fetches multiple rows from a query directly into a PL/SQL collection in a single database call:

DECLARE
  -- Define collection types
  TYPE num_tab   IS TABLE OF employees.employee_id%TYPE;
  TYPE str_tab   IS TABLE OF employees.last_name%TYPE;
  TYPE num_tab2  IS TABLE OF employees.salary%TYPE;
  
  l_emp_ids   num_tab;
  l_names     str_tab;
  l_salaries  num_tab2;
BEGIN
  -- Fetch all matching rows in one database call
  SELECT employee_id, last_name, salary
  BULK COLLECT INTO l_emp_ids, l_names, l_salaries
  FROM employees
  WHERE department_id = 20
  ORDER BY last_name;
  
  -- Process the data in memory (fast: no more DB round trips)
  FOR i IN 1..l_emp_ids.COUNT LOOP
    DBMS_OUTPUT.PUT_LINE(
      l_names(i) || ' (ID: ' || l_emp_ids(i) || ') — Salary: ' || l_salaries(i)
    );
  END LOOP;
END;

Using %ROWTYPE for Cleaner Bulk Collect

Instead of separate collections per column, use a table of records with %ROWTYPE:

DECLARE
  TYPE emp_tab IS TABLE OF employees%ROWTYPE;
  l_employees emp_tab;
BEGIN
  SELECT * BULK COLLECT INTO l_employees
  FROM employees
  WHERE department_id = 20;
  
  FOR i IN 1..l_employees.COUNT LOOP
    DBMS_OUTPUT.PUT_LINE(
      l_employees(i).last_name || ' — ' || l_employees(i).salary
    );
  END LOOP;
END;

LIMIT: Controlling Memory with Batch Processing

For very large tables, fetching everything at once with BULK COLLECT can consume too much memory. The LIMIT clause lets you process in batches:

DECLARE
  TYPE emp_tab IS TABLE OF employees%ROWTYPE;
  l_employees emp_tab;
  l_batch_size CONSTANT NUMBER := 1000;
  
  CURSOR emp_cur IS
    SELECT * FROM employees ORDER BY employee_id;
    
BEGIN
  OPEN emp_cur;
  
  LOOP
    -- Fetch up to 1000 rows at a time
    FETCH emp_cur BULK COLLECT INTO l_employees LIMIT l_batch_size;
    
    -- Process this batch
    FOR i IN 1..l_employees.COUNT LOOP
      -- Process each employee record
      NULL;
    END LOOP;
    
    -- Exit when fewer rows than LIMIT were fetched (means we're done)
    EXIT WHEN l_employees.COUNT < l_batch_size;
  END LOOP;
  
  CLOSE emp_cur;
END;

A batch size of 500–1000 rows is usually a good balance between memory usage and performance. Tune based on your row size and available PGA memory.

FORALL: Bulk DML Operations

FORALL sends an entire collection to the SQL engine in one call for DML operations (INSERT, UPDATE, DELETE). This is the DML counterpart to BULK COLLECT:

DECLARE
  TYPE num_tab IS TABLE OF NUMBER;
  l_emp_ids  num_tab;
  l_salaries num_tab;
BEGIN
  -- Load data to process
  SELECT employee_id, salary
  BULK COLLECT INTO l_emp_ids, l_salaries
  FROM employees
  WHERE department_id = 20;
  
  -- Multiply all salaries by 1.10 in one bulk operation
  -- Instead of: FOR i IN 1..l_emp_ids.COUNT LOOP UPDATE ... END LOOP;
  FORALL i IN 1..l_emp_ids.COUNT
    UPDATE employees
    SET    salary = l_salaries(i) * 1.10
    WHERE  employee_id = l_emp_ids(i);
  
  COMMIT;
  DBMS_OUTPUT.PUT_LINE(SQL%ROWCOUNT || ' employees updated.');
END;

The FORALL sends all the UPDATE statements as a batch to the SQL engine — one context switch instead of thousands.

FORALL with INSERT

DECLARE
  TYPE rec_tab IS TABLE OF new_employees%ROWTYPE;
  l_new_emps rec_tab;
BEGIN
  -- Fetch employees from staging table
  SELECT * BULK COLLECT INTO l_new_emps
  FROM staging_employees
  WHERE processed = 'N';
  
  -- Bulk insert into target table
  FORALL i IN 1..l_new_emps.COUNT
    INSERT INTO new_employees VALUES l_new_emps(i);
  
  -- Mark as processed
  FORALL i IN 1..l_new_emps.COUNT
    UPDATE staging_employees
    SET processed = 'Y'
    WHERE employee_id = l_new_emps(i).employee_id;
  
  COMMIT;
  DBMS_OUTPUT.PUT_LINE(l_new_emps.COUNT || ' employees processed.');
END;

Error Handling with SAVE EXCEPTIONS

By default, FORALL stops at the first error. The SAVE EXCEPTIONS clause lets FORALL continue processing remaining rows even when some fail, collecting all errors for review:

DECLARE
  TYPE num_tab IS TABLE OF NUMBER;
  l_ids    num_tab := num_tab(101, 999, 102, 103, 888); -- 999 and 888 don't exist
  
  bulk_errors EXCEPTION;
  PRAGMA EXCEPTION_INIT(bulk_errors, -24381);
BEGIN
  FORALL i IN 1..l_ids.COUNT SAVE EXCEPTIONS
    DELETE FROM employees WHERE employee_id = l_ids(i);
  
  COMMIT;
  
EXCEPTION
  WHEN bulk_errors THEN
    DBMS_OUTPUT.PUT_LINE('Errors encountered: ' || SQL%BULK_EXCEPTIONS.COUNT);
    
    FOR i IN 1..SQL%BULK_EXCEPTIONS.COUNT LOOP
      DBMS_OUTPUT.PUT_LINE(
        'Error at index ' || SQL%BULK_EXCEPTIONS(i).ERROR_INDEX || 
        ': ORA-' || SQL%BULK_EXCEPTIONS(i).ERROR_CODE
      );
    END LOOP;
    
    COMMIT; -- Commit successful deletes even with partial failures
END;

SQL%BULK_EXCEPTIONS is a collection of error records, each containing ERROR_INDEX (which element failed) and ERROR_CODE (the Oracle error number). This gives you complete visibility into which rows succeeded and which failed.

Combining BULK COLLECT + FORALL: The Complete Pattern

DECLARE
  TYPE emp_id_tab IS TABLE OF employees.employee_id%TYPE;
  l_emp_ids emp_id_tab;
  l_batch_size CONSTANT NUMBER := 500;
  
  CURSOR inactive_emp_cur IS
    SELECT employee_id FROM employees
    WHERE last_login < SYSDATE - 365
    AND   status = 'ACTIVE';
    
BEGIN
  OPEN inactive_emp_cur;
  
  LOOP
    FETCH inactive_emp_cur BULK COLLECT INTO l_emp_ids LIMIT l_batch_size;
    EXIT WHEN l_emp_ids.COUNT = 0;
    
    -- Bulk update all fetched records at once
    FORALL i IN 1..l_emp_ids.COUNT
      UPDATE employees
      SET status = 'INACTIVE', deactivated_date = SYSDATE
      WHERE employee_id = l_emp_ids(i);
    
    COMMIT; -- Commit each batch to avoid large rollback segments
    
    DBMS_OUTPUT.PUT_LINE('Processed batch of ' || l_emp_ids.COUNT || ' employees.');
  END LOOP;
  
  CLOSE inactive_emp_cur;
END;

This pattern — batch BULK COLLECT in a loop + FORALL DML + batch COMMIT — is the gold standard for large-scale data processing in PL/SQL. It is memory-efficient, fast, and committs frequently to keep rollback manageable.

Performance Comparison

Approach100K RowsRelative Speed
Row-by-row cursor loopBaseline1x
BULK COLLECT + row-by-row update~2–3x fasterBetter
BULK COLLECT + FORALL~10–50x fasterMuch better
Pure SQL (single UPDATE)FastestBest if no PL/SQL logic needed

If you can express the operation as a single SQL statement, always prefer that over PL/SQL loops. Use BULK COLLECT + FORALL when the logic is too complex for a single SQL statement.

Conclusion

BULK COLLECT and FORALL are essential tools for any PL/SQL developer who works with significant data volumes. The performance improvement over row-by-row processing is dramatic and consistent. Once you adopt the bulk operation pattern, you will never go back to writing slow cursor loops for data processing tasks.

The key principles: always LIMIT your BULK COLLECT for large tables, use FORALL instead of loops for DML, use SAVE EXCEPTIONS when partial failures are acceptable, and commit by batch to keep rollback segments manageable.

PreviousNext