Deep Dive — BULK COLLECT
One context switch brings back a whole batch of rows, not one. That single change is usually the biggest speed-up a PL/SQL loop ever gets.
1. What It Is and When
Every FETCH (and every SELECT INTO) is a round trip between the PL/SQL engine and the SQL engine — a context switch. BULK COLLECT fetches many rows in one switch, straight into a collection. On 500M rows the difference between one switch per row and one per 10,000 rows is the difference between hours and minutes.
It appears in four places:
| Where | Form |
|---|---|
| Implicit query | SELECT ... BULK COLLECT INTO <coll> |
| Explicit cursor | FETCH <cur> BULK COLLECT INTO <coll> [LIMIT n] |
| Dynamic SQL | EXECUTE IMMEDIATE '...' BULK COLLECT INTO <coll> |
| DML returning | DELETE ... RETURNING <col> BULK COLLECT INTO <coll> |
Use it whenever PL/SQL needs to look at the rows. If it does not — if the logic can live in SQL — use no loop at all (Chapter 1).
2. Syntax
-- into one collection of records
SELECT <cols> BULK COLLECT INTO <coll_of_records> FROM ... ;
-- into several scalar collections, in select-list order
SELECT <c1>, <c2> BULK COLLECT INTO <coll1>, <coll2> FROM ... ;
-- explicit cursor, batched
FETCH <cursor> BULK COLLECT INTO <coll> [LIMIT <n>];
-- dynamic
EXECUTE IMMEDIATE <sql> BULK COLLECT INTO <coll> [USING <binds>];
-- DML
{INSERT | UPDATE | DELETE} ... RETURNING <cols> BULK COLLECT INTO <coll>;
The target collection can be an associative array, nested table or varray (a varray must be large enough). It is always filled densely from index 1, and its previous contents are discarded.
3. Examples on the Course Schema
3a. A known-small result — no cursor, no LIMIT
DECLARE
TYPE t_order_rec IS RECORD (order_id orders.order_id%TYPE, amount orders.amount%TYPE);
TYPE t_orders IS TABLE OF t_order_rec;
l_orders t_orders;
BEGIN
SELECT order_id, amount BULK COLLECT INTO l_orders
FROM orders
WHERE customer_id = :id AND status = 'NEW'; -- a few dozen rows per customer
FOR i IN 1 .. l_orders.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(l_orders(i).order_id || ': ' || l_orders(i).amount);
END LOOP;
END;
Note: no NO_DATA_FOUND when the result is empty — l_orders.COUNT = 0, that is all.
3b. Several scalar collections
DECLARE
TYPE t_ids IS TABLE OF orders.order_id%TYPE;
TYPE t_amts IS TABLE OF orders.amount%TYPE;
l_ids t_ids;
l_amts t_amts;
BEGIN
SELECT order_id, amount BULK COLLECT INTO l_ids, l_amts
FROM orders WHERE customer_id = :id;
END;
Records (3a) keep the row together; parallel scalar collections are what older FORALL code needed. Since 11g FORALL handles record fields, so prefer records.
3c. The canonical batched loop — cursor + LIMIT
DECLARE
CURSOR c IS SELECT order_id, customer_id, amount FROM orders WHERE status = 'NEW';
TYPE t_rows IS TABLE OF c%ROWTYPE;
l_rows t_rows;
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO l_rows LIMIT 10000;
EXIT WHEN l_rows.COUNT = 0;
-- process l_rows(1 .. COUNT) in memory, then FORALL the changes back
FORALL i IN 1 .. l_rows.COUNT
UPDATE orders SET status = 'SEEN' WHERE order_id = l_rows(i).order_id;
COMMIT;
END LOOP;
CLOSE c;
END;
c%ROWTYPE as the element type means adding a column to the cursor never breaks the collection declaration. See LIMIT for the %NOTFOUND trap.
3d. RETURNING BULK COLLECT — know what you just changed
DECLARE
TYPE t_ids IS TABLE OF orders.order_id%TYPE;
l_deleted t_ids;
BEGIN
DELETE FROM orders
WHERE status = 'CANCELLED' AND order_date < ADD_MONTHS(SYSDATE, -36)
RETURNING order_id BULK COLLECT INTO l_deleted;
FORALL i IN 1 .. l_deleted.COUNT
INSERT INTO orders_purge_log (order_id, purged_at) VALUES (l_deleted(i), SYSTIMESTAMP);
END;
Also works after a FORALL — FORALL ... UPDATE ... RETURNING ... BULK COLLECT INTO collects across all iterations.
3e. Dynamic SQL
DECLARE
TYPE t_ids IS TABLE OF orders.order_id%TYPE;
l_ids t_ids;
BEGIN
EXECUTE IMMEDIATE
'SELECT order_id FROM ' || DBMS_ASSERT.SQL_OBJECT_NAME(:tab) || ' WHERE status = :1'
BULK COLLECT INTO l_ids
USING 'NEW';
END;
This form has no LIMIT. For large dynamic results use OPEN FOR + FETCH ... BULK COLLECT ... LIMIT.
4. What Breaks at Scale
- PGA.
SELECT ... BULK COLLECT INTOwith no cursor brings everything into session memory. 500M rows × 50 bytes = 25 GB →ORA-04030orORA-04036(PGA_AGGREGATE_LIMIT). Anything whose size you do not know in advance goes through a cursor withLIMIT. - The batch is the unit of everything. Fetch size,
FORALLsize, commit frequency (COMMIT), error scope (SAVE EXCEPTIONS) — all follow theLIMIT. 1,000–10,000 is the range; larger batches stop paying off and start costing memory. - Diminishing returns. Switch overhead is fixed per fetch; going from 1 → 100 rows per fetch saves ~99% of it, 100 → 10,000 saves most of the remaining 1%. The cursor FOR loop already gives you 100 for free (CURSOR FOR LOOP) — for read-only loops that may be enough.
- LOBs. A collection of rows with LOB columns holds LOB locators, not the data; fetching them in bulk is fine, but reading each LOB afterwards is a round trip per row.
5. Pitfalls
NO_DATA_FOUNDis not raised on an empty result. CheckCOUNT = 0.EXIT WHEN c%NOTFOUNDplaced right after aFETCH ... LIMIT— drops the last, partial batch. UseEXIT WHEN coll.COUNT = 0, or test%NOTFOUNDafter the processing.- The collection is overwritten on every fetch, not appended. If you need to accumulate, copy the elements out.
BULK COLLECT INTOa varray with aLIMITsmaller than the result →ORA-22165. Use nested tables or associative arrays.SELECT ... BULK COLLECTin a loop over a "parent" cursor — the N+1 pattern in bulk clothing. Join instead.- Column order must match the collection order in the multi-collection form (3b). A record type with
%ROWTYPEavoids that class of bug. - A collection declared at package level keeps its memory until you
DELETEit — see COLLECTION METHODS.