Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with oracle-free 23.26.3.0.0, mssql 17.0.5005.3, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: Why one call for a batch costs less than one call per row · SELECT BULK COLLECT INTO fills a collection in one call · A LIMIT loop that keeps the last batch. With a pass: 2 in-depth parts and 3 interview answers, about 10 minutes.

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 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:

bulk_select.sql: every ledger entry, and an empty result SQL · basics/bulk_select.sql
-- 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, run in the lab Python · bulk_select_session.py
"""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

  1. Question 1 of 2 What does PL/SQL hand to the SQL engine for each FETCH of a one-row-at-a-time cursor loop?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: One request, a hand-over known as a context switch

    BULK COLLECT batches the work so one call does what many calls did one row at a time.

  2. Question 2 of 2 A SELECT ... BULK COLLECT INTO v_rows finds no row. What does the block see next?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: v_rows.COUNT = 0, and no exception is raised

    Check COUNT before reading index 1, because an empty collection has no first element.

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:

Interactive · message by message Three fetches at LIMIT 2 over 5 rows Step through each fetch and watch which rows the collection holds and when the exit test runs.
Three fetches at LIMIT 2 over 5 rows, step 10 of 10. Messages so far: PL/SQL block to SQL engine: FETCH LIMIT 2; SQL engine to PL/SQL block: 2 rows; PL/SQL block to SQL engine: FETCH LIMIT 2; SQL engine to PL/SQL block: 2 rows; PL/SQL block to SQL engine: FETCH LIMIT 2; SQL engine to PL/SQL block: 1 row.PL/SQL block5 rows writtenSQL enginecursor closedFETCH LIMIT 22 rowsFETCH LIMIT 22 rowsFETCH LIMIT 21 row

The drawing shows the whole scene; its 10 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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:

limit_loop.sql: batches of 2, 2 and 1 on the practice ledger SQL · limit/limit_loop.sql
-- 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, run in the lab Python · limit_loop_session.py
"""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

  1. One question Which line order of a LIMIT loop body processes every batch, including the last short one?

    Choose one answer.

    Show the answer: One question

    Answer: FETCH; FOR i IN 1 .. v_rows.COUNT LOOP ... END LOOP; EXIT WHEN c%NOTFOUND

    A second safe form puts EXIT WHEN v_rows.COUNT = 0 as the first line of the body, at the cost of an empty fetch.

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.

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.

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.

  1. Question 1 of 6 An explicit FETCH ... INTO loop reads 400 rows one at a time and exits on the fetch that finds no row. How many fetch calls does it make?

    Type a number.

    Show the answer: Question 1 of 6

    Answer: 401

    One call per row and one more that returns nothing; BULK COLLECT without a LIMIT would read all 400 in one call.

  2. Question 2 of 6 Oracle's bulk SQL page gives a lower bound on the result size at which bulk SQL begins to pay off. How many rows is it?

    Type a number.

    Show the answer: Question 2 of 6

    Answer: 4

    The page says four or more rows; above that the saving on context switches is worth paying for.

  3. Question 3 of 6 Which exception does SELECT ... BULK COLLECT INTO raise when the query returns no row?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: None; the collection is left empty and the block runs on

    Check v.COUNT before reading index 1, because the collection has no first element.

  4. Question 4 of 6 A SELECT item_name BULK COLLECT INTO v_names FROM stock WHERE on_hand > 10000 finds no row on the practice schema. What does v_names.COUNT return?

    Type a number.

    Show the answer: Question 4 of 6

    Answer: 0

    An empty result gives an empty collection, so COUNT is 0 and no exception is raised.

  5. Question 5 of 6 Oracle's Example 13-24 of a LIMIT loop puts EXIT WHEN c%NOTFOUND on which line of the body?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: The last statement of the loop body

    A second safe form uses EXIT WHEN v_rows.COUNT = 0 as the first line, which costs one empty fetch.

  6. Question 6 of 6 A LIMIT loop reads 7 numbered rows with LIMIT 3 and EXIT WHEN c%NOTFOUND as the LAST line of the body. How many FETCH statements does the loop make?

    Type a number.

    Show the answer: Question 6 of 6

    Answer: 3

    Fetch 1 returns 3 rows, fetch 2 returns 3, fetch 3 returns 1 and sets the attribute true; the body writes the final row before the loop exits.

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:

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 INTO fills 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 n with 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.