Deep Dive — LIMIT

A batch size is a promise about memory: no matter how many rows the cursor returns, this loop never holds more than N.

Used in
Chapter 2 — Bulk
Appendix entry
LIMIT
Related
BULK COLLECT FORALL COMMIT

LIMIT caps the number of rows a single FETCH ... BULK COLLECT brings back. It is the difference between "bring me everything" and "bring me the next 10,000". Every bulk loop over a result whose size you do not control must have it.

SQL
FETCH <cursor> BULK COLLECT INTO <collection(s)> LIMIT <n>;

<n> is any positive integer expression — a literal, a constant, a parameter, a package variable. It applies only to FETCH; there is no LIMIT on SELECT ... BULK COLLECT INTO or on EXECUTE IMMEDIATE ... BULK COLLECT INTO.

SQL
DECLARE
  CURSOR c IS SELECT order_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;          -- (1) test the collection, not the cursor
    -- ... process l_rows ...
  END LOOP;
  CLOSE c;
END;

Why (1) matters: after the fetch that returns the last, partial batch (say 3,417 rows), c%NOTFOUND is already TRUE — the cursor knows there is nothing more. EXIT WHEN c%NOTFOUND at that point skips those 3,417 rows. Either test COUNT = 0, or move EXIT WHEN c%NOTFOUND to the end of the loop body, after processing.

SQL
CREATE OR REPLACE PROCEDURE reprice_new_orders (p_batch_size PLS_INTEGER DEFAULT 10000) IS
  CURSOR c IS SELECT order_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 p_batch_size;
    EXIT WHEN l_rows.COUNT = 0;
    FOR i IN 1 .. l_rows.COUNT LOOP
      l_rows(i).amount := pricing_pkg.reprice(l_rows(i).order_id, l_rows(i).amount);
    END LOOP;
    FORALL i IN 1 .. l_rows.COUNT
      UPDATE orders SET amount = l_rows(i).amount WHERE order_id = l_rows(i).order_id;
    COMMIT;
  END LOOP;
  CLOSE c;
END;
/

A parameter lets you tune in production without recompiling — and lets the same procedure run with LIMIT 500 on a small dev box.

SQL
DECLARE
  CURSOR c IS SELECT * FROM orders WHERE status = 'NEW';
  TYPE t_rows IS TABLE OF c%ROWTYPE;
  l_rows t_rows;
  l_pga0 NUMBER; l_pga1 NUMBER;
BEGIN
  SELECT value INTO l_pga0 FROM v$mystat m JOIN v$statname n ON n.statistic# = m.statistic#
  WHERE  n.name = 'session pga memory';
  OPEN c;
  FETCH c BULK COLLECT INTO l_rows LIMIT 100000;
  SELECT value INTO l_pga1 FROM v$mystat m JOIN v$statname n ON n.statistic# = m.statistic#
  WHERE  n.name = 'session pga memory';
  CLOSE c;
  DBMS_OUTPUT.PUT_LINE('100K rows of orders%ROWTYPE ~ ' || ROUND((l_pga1 - l_pga0) / 1024 / 1024) || ' MB');
END;

Typical result for a 5-column row: 15–30 MB per 100K rows. Multiply by concurrent sessions running the job.

Illustrative timings for a 10M-row loop with one FORALL update per batch (numbers vary by row size and hardware; the shape does not):

LIMITFetchesRuntimePGA per session
1 (row by row)10,000,000~55 minnegligible
100100,000~6 min< 1 MB
1,00010,000~2.5 min~1 MB
10,0001,000~2 min~10 MB
100,000100~2 min~100 MB
no LIMIT1~2 min or ORA-04030~10 GB

From 1,000 upward the curve is flat. Go higher only for a reason (fewer commits, fewer FORALL statements), never by default.

  • No LIMIT on an unbounded cursor = the whole result in PGA. On orders that is ORA-04030: out of process memory after a few minutes, or ORA-04036 when PGA_AGGREGATE_LIMIT is hit — which can kill other sessions' work too.
  • The batch is the commit unit (COMMIT): 10K–50K rows per commit is the sweet spot between undo held and commits issued.
  • The batch is the error unit (SAVE EXCEPTIONS): with SAVE EXCEPTIONS a failure is confined to one batch; without it, one bad row rolls back the whole FORALL of that batch.
  • Concurrency multiplies memory. 8 parallel workers × 100 MB batches = 800 MB of PGA for one job. Size for the number of workers, not for one session.

  1. EXIT WHEN c%NOTFOUND immediately after the fetch — loses the last batch. The single most common bulk bug.
  2. LIMIT on SELECT ... BULK COLLECT INTO — syntax error; it is FETCH-only.
  3. Batches so large that one batch takes minutes — a crash loses minutes of work; a lock is held for minutes (FOR UPDATE).
  4. LIMIT too small (10, 50) "to be safe" — you are back to context-switch overhead. 1,000 is the sane minimum.
  5. Assuming the collection is emptied by DELETE between fetches — not needed; BULK COLLECT replaces the contents. Calling DELETE costs nothing either, so do not worry about it.
  6. COUNT = 0 test with a collection that was never fetched (cursor failed to open) — the OPEN raises first; no issue in practice.