PL/SQL & T-SQL (Oracle and SQL Server) Module 10 – Cursors, collections and bulk processing
BULK COLLECT with LIMIT
Fetch many rows in one call with BULK COLLECT INTO, loop with LIMIT so every batch is processed, and pick a batch size for your data.
What you will learn
- Retrieve many rows in one call with BULK COLLECT INTO
- Write a LIMIT loop that processes every batch, the last partial one too
- Choose a batch size from fetch calls and memory
- Use RETURNING with BULK COLLECT INTO to capture the rows a change touched
Before you start
What you’ll learn · Before you start
What you will learn
- Retrieve many rows in one call with BULK COLLECT INTO
- Write a LIMIT loop that processes every batch, the last partial one too
- Choose a batch size from fetch calls and memory
- Use RETURNING with BULK COLLECT INTO to capture the rows a change touched
Before you start
On this page
On the practice ledger a loop that fetches its 5 rows one at a time makes 6 trips to the SQL engine, one per row and a last one that finds nothing; one SELECT with BULK COLLECT fills a collection with every row in a single trip. On 100,000 rows that is one trip instead of 100,001, and BULK COLLECT with LIMIT lets code hold a batch, work through it, and ask for the next one.
You will fetch many rows at once, write a batch loop that handles every row in the last short batch too, pick a size for your data, and read back the rows a change touched in one statement.
Why one call for a batch costs less than one call per row
PL/SQL and SQL are two engines inside one database. Each SELECT or DML statement in a block hands a request to the SQL engine and takes the answer back, a hand-over known as a context switch: The handover of control between the PL/SQL and SQL engines of an Oracle database; every SELECT or DML statement in a block pays one, and bulk SQL does the same work for many rows in one call.. A FETCH loop that reads one row at a time pays the switch per row, and Oracle’s bulk SQL page notes the saving begins to pay off for four rows or more. A cursor FOR loop already batches its fetches at the default optimize level, as the first lesson of this module measures.
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. is two features: BULK COLLECT: A PL/SQL clause that fills one or more collections with every row a statement returns in one call, as a SELECT INTO or a FETCH; an empty result raises no NO_DATA_FOUND. returns rows from SQL to PL/SQL in batches, and FORALL sends DML the other way, which the next lesson covers. The three forms this lesson teaches read 100,000 rows at these counts of trips:
| Form | Trips for 100,000 rows |
|---|---|
| FETCH … INTO loop, one row a time | 100,001 |
| BULK COLLECT INTO, every row at once | 1 |
| FETCH BULK COLLECT INTO … LIMIT 1,000 | 101 |
A larger LIMIT means fewer trips and more rows held in memory at once. A DML statement can fill collections too: with RETURNING … BULK COLLECT INTO it hands back the columns of every row it changed.
SELECT BULK COLLECT INTO fills a collection in one call
SELECT … BULK COLLECT INTO fills a collection with every row of the query in one trip, with the shape a SELECT INTO uses for a single row. The clause raises no NO_DATA_FOUND on an empty result; code must check COUNT before it reads index 1:
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- Shows SELECT ... BULK COLLECT INTO on the practice ledger: one query that returns every entry into an array in
-- one call, and a second query that returns no row and leaves the array empty without raising NO_DATA_FOUND.
-- Loads nothing: it reads the practice schema that schema_oracle.sql of practice-schema-and-dialect-basics creates.
-- Python counterpart that applies the same rule in standard arithmetic: examples/model/batch_model.py.
SET SERVEROUTPUT ON FORMAT WRAPPED
DECLARE
TYPE amount_tab IS TABLE OF ledger.amount%TYPE INDEX BY PLS_INTEGER;
v_amounts amount_tab;
BEGIN
SELECT amount
BULK COLLECT INTO v_amounts
FROM ledger
ORDER BY entry_id;
DBMS_OUTPUT.PUT_LINE('ledger entries read: ' || v_amounts.COUNT);
FOR i IN 1 .. v_amounts.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(' amount ' || i || ' = ' || v_amounts(i));
END LOOP;
-- Expected (from schema_oracle.sql): COUNT 5, then the 5 amounts in entry_id order as a NUMBER prints when it is
-- joined to text, without trailing zeros: 2500, 18000, 1000, -239.75, 41250.5.
SELECT amount
BULK COLLECT INTO v_amounts
FROM ledger
WHERE amount > 100000.00;
DBMS_OUTPUT.PUT_LINE('ledger entries over 100000: ' || v_amounts.COUNT);
IF v_amounts.COUNT = 0 THEN
DBMS_OUTPUT.PUT_LINE(' no row met the filter; the collection is empty.');
END IF;
-- Expected: COUNT 0, no NO_DATA_FOUND; a plain SELECT INTO on the same filter would have raised ORA-01403.
END;
/ """bulk_select.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("basics/bulk_select.sql") Output
$ sqlplus -s lab@//localhost/FREEPDB1 @basics/bulk_select.sql ledger entries read: 5 amount 1 = 2500 amount 2 = 18000 amount 3 = 1000 amount 4 = -239.75 amount 5 = 41250.5 ledger entries over 100000: 0 no row met the filter; the collection is empty. PL/SQL procedure successfully completed.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bulk_select_session.py
The first query loads the 5 entries into one array. The second asks for entries with an amount above 100,000.00 and
finds none; the block reads COUNT as 0 and says so, instead of leaning on index 1 that no row ever wrote. The
whole-result form fits lookups whose row count is small by design, and the batch loop below is for larger results.
Checkpoint
Check: Why one call for a batch costs less · SELECT BULK COLLECT INTO on one query
A LIMIT loop that keeps the last batch
For a long table, BULK COLLECT without a cap holds every row in session memory. FETCH … BULK COLLECT INTO with a LIMIT clause: An option on FETCH BULK COLLECT INTO that caps the number of rows one call returns, so a loop can read a large result in batches rather than hold the whole result in memory. is the long-table form: a cursor opened once, the batch handled inside the loop, the loop left when the cursor is empty. Where the exit sits decides which rows the loop keeps:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- Open the cursor. The block declares a cursor over the five ledger entries and opens it. The SQL engine readies the result and returns control; no row has crossed yet.
- Fetch 1 of up to 2. FETCH ... BULK COLLECT INTO v_rows LIMIT 2 asks for a batch of two rows. One trip to the SQL engine.
- Receive entries 1 and 2. The SQL engine returns two rows, entry 1 and entry 2. The collection holds two items at index 1 and 2, and c%NOTFOUND is false because the batch filled the LIMIT.
- Process the batch, then test. The FOR loop writes each of the two entries. Only then does the body test c%NOTFOUND, which is false, and the loop goes on. The batch is already handled.
- Fetch 2 of up to 2. The loop asks for another batch of two rows. One more trip to the SQL engine, which has three rows left to send.
- Receive entries 3 and 4. Two more rows arrive. The collection now holds entry 3 and entry 4, and c%NOTFOUND is still false because the batch filled the LIMIT.
- Process and test again. The body writes both entries, then tests c%NOTFOUND and finds it still false. The loop goes on. Four of five entries written so far.
- Fetch 3, one row left. The third FETCH asks for two rows and the SQL engine has only one left. It returns entry 5 and marks the cursor done; c%NOTFOUND is set true for this fetch.
- Receive the final partial batch. One row arrives. The collection holds entry 5 at index 1, COUNT is 1 and c%NOTFOUND is true because the fetch returned fewer rows than the LIMIT.
- Process the batch, then exit. The body writes entry 5 first. Only then does the loop read c%NOTFOUND, which is true, and leave. All five entries are written, and the cursor is closed.
The script follows Oracle’s Example 13-24, EXIT WHEN c%NOTFOUND as the LAST statement of the body, so the
test runs on a batch already written. A second safe form, Oracle’s Example 13-22, uses EXIT WHEN v_rows.COUNT = 0
as the FIRST statement, one more empty fetch for an exit that needs no reordering:
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- Shows FETCH ... BULK COLLECT INTO ... LIMIT 2 over the 5 practice-ledger rows, with the exit test placed as the
-- LAST statement of the loop body so that the final partial batch is processed. Three fetches, batches of 2, 2, 1.
-- Reads schema_oracle.sql of practice-schema-and-dialect-basics; makes no changes. Python model: batch_model.py.
SET SERVEROUTPUT ON FORMAT WRAPPED
DECLARE
TYPE entry_tab IS TABLE OF ledger%ROWTYPE INDEX BY PLS_INTEGER;
v_rows entry_tab;
v_batch PLS_INTEGER := 0;
CURSOR c IS
SELECT * FROM ledger ORDER BY entry_id;
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO v_rows LIMIT 2;
v_batch := v_batch + 1;
DBMS_OUTPUT.PUT_LINE('batch ' || v_batch || ', rows ' || v_rows.COUNT);
FOR i IN 1 .. v_rows.COUNT LOOP
DBMS_OUTPUT.PUT_LINE(' entry_id ' || v_rows(i).entry_id || ', amount ' || v_rows(i).amount);
END LOOP;
EXIT WHEN c%NOTFOUND;
END LOOP;
CLOSE c;
-- Expected output: batch 1 rows 2 (entries 1, 2); batch 2 rows 2 (entries 3, 4); batch 3 rows 1 (entry 5),
-- then exit because the partial batch sets c%NOTFOUND true. All 5 entries printed.
-- Second safe form: EXIT WHEN v_rows.COUNT = 0 as the FIRST statement of the loop body. Resets the counter so
-- that this run and the previous one print separate batch numbers.
v_batch := 0;
OPEN c;
LOOP
FETCH c BULK COLLECT INTO v_rows LIMIT 2;
EXIT WHEN v_rows.COUNT = 0;
v_batch := v_batch + 1;
DBMS_OUTPUT.PUT_LINE('count-exit batch ' || v_batch || ', rows ' || v_rows.COUNT);
END LOOP;
CLOSE c;
-- Expected: three batches as above (2, 2, 1); the loop then runs one more fetch that returns 0 rows and leaves.
END;
/ """limit_loop.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("limit/limit_loop.sql") Output
$ sqlplus -s lab@//localhost/FREEPDB1 @limit/limit_loop.sql batch 1, rows 2 entry_id 1, amount 2500 entry_id 2, amount 18000 batch 2, rows 2 entry_id 3, amount 1000 entry_id 4, amount -239.75 batch 3, rows 1 entry_id 5, amount 41250.5 count-exit batch 1, rows 2 count-exit batch 2, rows 2 count-exit batch 3, rows 1 PL/SQL procedure successfully completed.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 limit_loop_session.py
Three batches of 2, 2 and 1 together print every entry. An exit tested before the body would skip the last short batch.
Checkpoint
Check: A LIMIT loop that keeps the last batch
Reproducing the lost last batch, and the exits that keep it Pro: opens with a Pro, Premium or Ultimate pass
A loop that reads 25 rows at LIMIT 10 with EXIT WHEN c%NOTFOUND placed right after the FETCH, before the batch is
processed, leaves 5 rows unexported. The fault is in the order of the lines, not the fetch, and this part reproduces
it on 25 generated statement rows and shows the two exits that keep every batch.
In this part:
- Three fetches drop five of twenty-five rows
- Two exits that keep every batch
1 diagram · 3 runnable examples (Python, SQL) · about 6 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).
Choosing a batch size, and RETURNING ... BULK COLLECT INTO Pro: opens with a Pro, Premium or Ultimate pass
Without a LIMIT every row of the query sits in the session’s own memory, the PGA: The Program Global Area: the memory an Oracle database keeps per session for private data, including PL/SQL variables and the rows a BULK COLLECT holds; a larger batch makes a session's PGA grow.; with a LIMIT the loop trades fetch calls for the memory one batch holds. This part reads the two numbers for LIMIT 10, 100, 1,000 and 10,000 over 200,000 generated rows, then shows RETURNING … BULK COLLECT INTO for a static UPDATE and an EXECUTE IMMEDIATE.
In this part:
- Fetch calls and rows held for four batch sizes
- RETURNING many rows into collections from one DML
4 runnable examples (Python, SQL) · about 11 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: predict the collection, the count or the fetch
Each question shows a short block on the practice data. Work out what the collection holds, what COUNT returns, or which lines run, before you choose a reason:
Practice that adapts to you
Practise this lesson
6 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 does a cursor that reads one row at a time hand more work to the SQL engine than BULK COLLECT, for a hundred thousand rows?
Each SELECT or FETCH in a PL/SQL block hands control to the SQL engine and takes it back when the row arrives, a hand-over known as a context switch. A FETCH loop that reads one row at a time pays the switch for each of the hundred thousand rows. SELECT … BULK COLLECT INTO sends one request and reads every row back at once, so the loop pays one switch. FETCH … BULK COLLECT INTO … LIMIT 1,000 is the middle form: a hundred full batches plus one empty one that ends the loop, so about a hundred switches instead of a hundred thousand. Oracle’s bulk SQL page notes the saving begins to pay off for four rows or more.
Three longer questions follow, on a nightly export that misses rows, a report that runs out of memory, and reading back the ids of orders a change cancelled. Make your own first move for each before you open the first step:
-
Mid to senior
A nightly export block reads cancelled ledger entries into a file with FETCH ... BULK COLLECT INTO v_rows LIMIT 500 followed on the next line by EXIT WHEN c%NOTFOUND, then writes each v_rows item. The file holds 2,500 lines where the ledger holds 2,547. Explain the bug and fix it two ways.
-
Mid to senior
A reporting job reads 2 million ledger entries into a flat file with SELECT ... BULK COLLECT INTO v_rows and formats each row in PL/SQL. Some nights it fails with ORA-04030, out of process memory. Rewrite the loop to cap memory, choose a batch size, and describe how you measure that the size fits your data.
-
Senior to staff
A nightly job cancels orders older than 180 days with one UPDATE orders SET status = 'cancelled' WHERE ordered_on < sysdate - 180. Finance wants a daily list of the cancelled order_id and qty values for its reconciliation email. Return them from the UPDATE itself, for one row and for many.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does every SQL statement in a PL/SQL block cost?
One hand-over between the two engines: the block hands a request to the SQL engine and takes the answer back. A fetch loop pays it per row, and Oracle's bulk SQL page notes the saving from doing the work in one call begins to pay off for four rows or more.
-
What are the two halves of bulk SQL?
BULK COLLECT returns rows from SQL to PL/SQL in batches, and FORALL sends DML the other way.
-
How many trips to the SQL engine does each form make for 100,000 rows?
A FETCH ... INTO loop one row at a time: 100,001. BULK COLLECT INTO with no cap: 1. FETCH BULK COLLECT INTO with LIMIT 1,000: 101. A larger LIMIT means fewer trips and more rows held in memory at once.
-
What does SELECT ... BULK COLLECT INTO do when the query finds nothing?
Nothing: it raises no NO_DATA_FOUND, so the code must check COUNT before it reads index 1. The whole-result form fits lookups whose row count is small by design.
-
What is the long-table form of a bulk fetch?
FETCH ... BULK COLLECT INTO ... LIMIT n: a cursor opened once, the batch handled inside the loop, the loop left when the cursor is empty. Without a cap, BULK COLLECT holds every row in session memory.
-
Where may the exit of a LIMIT loop stand, and what happens if it stands first?
Last in the body, as EXIT WHEN c%NOTFOUND, so the test runs on a batch already written; or first, as EXIT WHEN v_rows.COUNT = 0, at the cost of one more empty fetch. Testing %NOTFOUND before the body skips the last short batch.
-
How does a DML statement hand back every row it changed?
With RETURNING ... BULK COLLECT INTO, which fills collections with the columns of every changed row in the same statement.
-
Does a cursor FOR loop already fetch in batches?
Yes, at the default optimize level, which the module's first lesson measures in V$SQL. BULK COLLECT with LIMIT adds control over the batch size and the memory it costs.
Key takeaways
- Each PL/SQL statement sent to the SQL engine pays one context switch; BULK COLLECT does the work of many rows in one call.
SELECT ... BULK COLLECT INTOfills a collection in one trip and raises no NO_DATA_FOUND on an empty result, so code checks COUNT first.FETCH ... BULK COLLECT INTO ... LIMIT nwith the exit as the last line of the body handles the last partial batch, which the wrong line order drops.
Terms in this lesson
- BULK COLLECT
- A PL/SQL clause that fills one or more collections with every row a statement returns in one call, as a SELECT INTO or a FETCH; an empty result raises no NO_DATA_FOUND.
- 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.
- context switch
- The handover of control between the PL/SQL and SQL engines of an Oracle database; every SELECT or DML statement in a block pays one, and bulk SQL does the same work for many rows in one call.
- LIMIT clause
- An option on FETCH BULK COLLECT INTO that caps the number of rows one call returns, so a loop can read a large result in batches rather than hold the whole result in memory.
- PGA
- The Program Global Area: the memory an Oracle database keeps per session for private data, including PL/SQL variables and the rows a BULK COLLECT holds; a larger batch makes a session's PGA grow.
References
- PL/SQL Language Reference 26ai: Bulk SQL and Bulk Binding (Oracle)
- PL/SQL Language Reference 26ai: FETCH Statement (Oracle)
- PL/SQL Language Reference 26ai: RETURNING INTO Clause (Oracle)
- PL/SQL Language Reference 26ai: EXECUTE IMMEDIATE Statement (Oracle)
- PL/SQL Language Reference 26ai: Named Cursor Attribute (Oracle)
- PL/SQL Language Reference 26ai: Collection Methods (Oracle)
- Database Reference 26ai: V$MYSTAT (Oracle)
- Database Reference 26ai: Statistics Descriptions (Oracle)
- PL/SQL Packages and Types Reference 26ai: DBMS_SESSION (Oracle)
- Security Guide 26ai: Considerations for Using Application-Based Security (Oracle)
- Database Error Messages: ORA-04030 (Oracle)
- Database Error Messages: ORA-04036 (Oracle)
Report a problem with this lesson
Kept only in this browser. Your Learn progress