Teach PL/SQL developers to write code that behaves well on hundreds of millions of rows — not just on 10,000.
Audience
Developers who already write PL/SQL: blocks, cursors, procedures, packages. There is no basics chapter.
Version
Oracle 19c and up. Every command is available from 12c.
Structure
10 chapters. Each chapter = one principle, a bad example vs. a recommended one, and commands linked to the appendix.
Duration
20–30 minutes per chapter. One working day in total.
The Thesis in Three Lines
SQL first. If it can be written as a single SQL statement — it is not PL/SQL.
Bulk when PL/SQL is unavoidable. Read and write in batches, never row by row.
Always measure. Without a number there is no optimization — only guessing.
Shared Example Schema
Every example in the course and the appendix runs on the same tables. Learn the context once.
Table
Columns
Size
customers
customer_id (PK), status, tier, last_order_date
5M rows
orders
order_id (PK), customer_id, order_date, amount, status
500M rows, partitioned by order_date (monthly: p_2024_12, …)
orders_archive
same as orders
archive
error_log
logged_at, ctx, err_code, err_msg, backtrace
error log
Business procedures that appear in the examples (pricing_pkg.reprice, process_order, apply_discount, load_orders) — their bodies do not matter; they stand for "logic that genuinely needs PL/SQL".
How to Read a Chapter
## Chapter N — Title
Principle — one sentence.
What breaks at scale — why it hurts specifically at 500M rows.
❌ Bad — code + why.
✅ Recommended — code + why.
Commands — each linked to the appendix: syntax + example + note.
Rule of thumb — one sentence to remember.
Principle: One SQL statement instead of a loop of statements.
What breaks at scale: A loop over 5M customers = 10M single-row statements, each one a switch between the PL/SQL and SQL engines. The same work as one MERGE = one switch, and the optimizer picks a hash join and a parallel full scan instead of 10M index probes.
See it run — 5M customers
Animation with two lanes. Loop: for each of 5M customers the PL/SQL engine sends SELECT COUNT(*), then UPDATE, to the SQL engine — 10M single-row statements and 10M index probes, one row at a time. MERGE: the PL/SQL engine sends one statement; the SQL engine does all the work as a hash join with a parallel full scan, and finishes first.
❌ Loop — two statements per customerdone ✓
PL/SQL engineSQL engine
customers5,000,000/ 5M
SQL statements10,000,000
index probes10,000,000
✅ MERGE — one statementdone ✓
PL/SQL engineSQL enginehash join + parallel full scan
customers5,000,000/ 5M
SQL statements1
index probes0
❌ Bad
DECLARE
v_cnt NUMBER;
BEGINFOR r IN (SELECT customer_id, MAX(order_date) AS last_dt
FROM orders GROUPBY customer_id) LOOPSELECT COUNT(*) INTO v_cnt
FROM customers WHERE customer_id = r.customer_id;
IF v_cnt > 0 THENUPDATE customers
SET last_order_date = r.last_dt
WHERE customer_id = r.customer_id;
ENDIF;
ENDLOOP;
COMMIT;
END;
Why:
Two statements per customer — 10M round-trips between the PL/SQL engine and the SQL engine.
COUNT(*) to test existence — counts every match when one is enough.
✅ Recommended
MERGEINTO customers c
USING (SELECT customer_id, MAX(order_date) AS last_dt
FROM orders
GROUPBY customer_id) o
ON (c.customer_id = o.customer_id)
WHENMATCHEDTHENUPDATESET c.last_order_date = o.last_dt;
-- Existence check: EXISTS stops at the first row foundSELECTCASEWHENEXISTS (SELECT 1 FROM orders WHERE customer_id = :id)
THEN 1 ELSE 0 ENDINTO v_has_orders
FROM dual;
Why:
One statement, one switch; the optimizer sees the whole picture and picks a set-based plan.
No "customer does not exist" case to handle — the ON clause does it.
Principle: When PL/SQL is unavoidable — read in batches (BULK COLLECT … LIMIT) and write in batches (FORALL).
What breaks at scale: Every FETCH and every single-row DML is a context switch. On 500M rows that is a billion switches — most of the runtime is overhead, not work.
See it run — 500M rows
Animation with two lanes. Row by row: the PL/SQL engine makes one FETCH and one UPDATE per row — on 500M rows that is a billion context switches. Bulk: every round trip carries 10,000 rows — a BULK COLLECT fetch, then one FORALL — 2 switches per 10,000 rows, 100,000 for all 500M rows, and it finishes first.
❌ Row by row — a FETCH and an UPDATE per rowdone ✓
PL/SQL engineSQL engine
rows500,000,000/ 500M
context switches1,000,000,000
✅ Bulk — 10,000 rows per round tripdone ✓
PL/SQL engineLIMIT keeps PGA flatSQL engine
rows500,000,000/ 500M
context switches100,000
❌ Bad
DECLARECURSOR c ISSELECT order_id, amount FROM orders WHERE status = 'NEW';
r c%ROWTYPE;
BEGINOPEN c;
LOOPFETCH c INTO r;
EXITWHEN c%NOTFOUND;
UPDATE orders
SET amount = pricing_pkg.reprice(r.order_id, r.amount)
WHERE order_id = r.order_id;
ENDLOOP;
CLOSE c;
COMMIT;
END;
Why:
One fetch + one update per row: twice as many context switches as rows.
pricing_pkg.reprice is called from inside the UPDATE — another switch per row (see Chapter 3).
✅ Recommended
DECLARECURSOR c ISSELECT order_id, amount FROM orders WHERE status = 'NEW';
TYPE t_rows ISTABLEOF c%ROWTYPE;
l_rows t_rows;
BEGINOPEN c;
LOOPFETCH c BULKCOLLECTINTO l_rows LIMIT 10000;
EXITWHEN l_rows.COUNT = 0;
FOR i IN 1 .. l_rows.COUNT LOOP-- logic in PL/SQL, in memory
l_rows(i).amount := pricing_pkg.reprice(l_rows(i).order_id, l_rows(i).amount);
ENDLOOP;
FORALL i IN 1 .. l_rows.COUNT -- one DML for the whole batchUPDATE orders SET amount = l_rows(i).amount
WHERE order_id = l_rows(i).order_id;
COMMIT;
ENDLOOP;
CLOSE c;
END;
Principle: A PL/SQL function inside SQL is a hidden loop. Logic in SQL first; a function only when there is no alternative — and then declared correctly.
What breaks at scale: A function in the WHERE clause on orders is called 500M times. If it runs its own SELECT — that is 500M hidden queries, and none of them show up in the execution plan.
See it run — 500M orders
Animation with two lanes. Function in WHERE: while it scans orders, the SQL engine calls is_vip() for every row, and the function runs its own SELECT on customers — 500M calls and 500M hidden queries that the execution plan does not show. JOIN: one statement; the optimizer runs a hash join with one scan of each table.
❌ Function in WHERE — a hidden SELECT per rowdone ✓
SQL enginethe plan shows an "innocent" full scanPL/SQL is_vip()
orders500,000,000/ 500M
function calls500,000,000
hidden queries500,000,000
✅ JOIN — the logic in SQLdone ✓
sessionSQL enginehash join — one scan of each table
orders500,000,000/ 500M
function calls0
hidden queries0
❌ Bad
CREATEORREPLACEFUNCTION is_vip (p_customer_id NUMBER) RETURNNUMBERIS
v_tier customers.tier%TYPE;
BEGINSELECT tier INTO v_tier FROM customers WHERE customer_id = p_customer_id;
RETURNCASEWHEN v_tier = 'VIP'THEN 1 ELSE 0 END;
END;
/
SELECT SUM(amount)
FROM orders
WHERE is_vip(customer_id) = 1; -- 500M calls, each with its own SELECT
Why:
A context switch per row + a SELECT per row — the plan shows an "innocent" full scan and hides the real cost.
The optimizer cannot use an index or a join — the logic is a black box.
✅ Recommended
-- 1. Logic in SQL: a JOIN instead of a functionSELECT SUM(o.amount)
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE c.tier = 'VIP';
-- 2. When a function is unavoidable (pure computation): declare it so the engine can save callsCREATEORREPLACEFUNCTION vat_amount (p_amount NUMBER) RETURNNUMBERDETERMINISTICISPRAGMAUDF;
BEGINRETURN ROUND(p_amount * 0.17, 2);
END;
/
Why:
The JOIN lets the optimizer pick a hash join — one scan of each table.
DETERMINISTIC lets Oracle skip repeated calls on the same input; PRAGMA UDF makes the SQL→PL/SQL switch cheaper. For a stable lookup table — RESULT_CACHE. For a one-off query — WITH FUNCTION.
Principle: A lookup table queried inside a loop — load it once into an in-memory collection and query that.
What breaks at scale:SELECT INTO inside a loop over 500M orders = 500M queries against customers. Even with a perfect index — each one costs a context switch, a cursor lookup and 3–4 block reads.
See it run — 500M orders
Animation with two lanes. SELECT INTO in the loop: for each of 500M orders the PL/SQL loop queries customers — 500M queries, each a context switch, a cursor lookup and 3–4 block reads. In-memory lookup: one query loads the VIP customers into a collection once; after that every order is checked in memory, with no SQL.
❌ SELECT INTO per orderdone ✓
PL/SQL loopcustomerseach: a switch, a cursor lookup, 3–4 block reads
orders500,000,000/ 500M
queries against customers500,000,000
✅ Load once, look up in memorydone ✓
PL/SQL loopin-memory hash lookup, no SQLcustomers
orders500,000,000/ 500M
queries against customers1
❌ Bad
DECLARE
v_tier customers.tier%TYPE;
BEGINFOR r IN (SELECT order_id, customer_id, amount FROM orders WHERE status = 'NEW') LOOPSELECT tier INTO v_tier
FROM customers WHERE customer_id = r.customer_id; -- 500M timesIF v_tier = 'VIP'THEN
apply_discount(r.order_id, r.amount);
ENDIF;
ENDLOOP;
END;
Why:
The same question ("what is customer X's tier") is asked hundreds of times for the same customer.
Every answer goes through the SQL engine although it could sit in session memory.
✅ Recommended
DECLARETYPE t_tier_map ISTABLEOF customers.tier%TYPEINDEXBYPLS_INTEGER;
l_tier t_tier_map;
BEGIN-- Load once. Only what is needed (VIP) — small, fits in PGA.FOR c IN (SELECT customer_id, tier FROM customers WHERE tier = 'VIP') LOOP
l_tier(c.customer_id) := c.tier;
ENDLOOP;
FOR r IN (SELECT order_id, customer_id, amount FROM orders WHERE status = 'NEW') LOOPIF l_tier.EXISTS(r.customer_id) THEN-- O(1), no SQL
apply_discount(r.order_id, r.amount);
ENDIF;
ENDLOOP; -- (the loop itself — Bulk, Chapter 2)END;
Why:
One query against customers instead of 500M; the lookup is an in-memory hash.
When passing the collection to another procedure — IN OUT NOCOPY, otherwise it gets copied.
Principle: COMMIT per batch, never per row. And if UNDO allows — once at the end.
What breaks at scale: Every COMMIT waits for the log writer (log file sync). 500M commits = 500M I/O waits. On top of that, COMMIT inside a loop over an open cursor is the classic cause of ORA-01555 snapshot too old — the job dies after 3 hours, halfway through.
See it run — 500M rows
Animation with two lanes. COMMIT per row: every row commits and waits for the log writer — 500M commits, 500M log file sync waits — and the COMMIT inside the open cursor kills the job halfway with ORA-01555 snapshot too old. COMMIT per batch: one COMMIT per 50,000 rows, about 10,000 commits in all, and every COMMIT is a recovery point.
❌ COMMIT per rowORA-01555 snapshot too old — dead halfway
sessionlog writer
rows committed250,000,000/ 500M
commits250,000,000
log file sync waits250,000,000
✅ COMMIT per 50,000-row batchdone ✓
sessioneach COMMIT = a recovery pointlog writer
rows committed500,000,000/ 500M
commits10,000
log file sync waits10,000
❌ Bad
BEGINFOR r IN (SELECT order_id FROM orders WHERE status = 'NEW') LOOPUPDATE orders SET status = 'PROCESSED'WHERE order_id = r.order_id;
COMMIT; -- 500M times: log file sync per row + ORA-01555 on the open cursorENDLOOP;
END;
Why:
Runtime is dominated by redo waits, not by the work.
No rollback possible: a failure halfway leaves half the data updated with no "starting point".
✅ Recommended
DECLARECURSOR c ISSELECT order_id FROM orders WHERE status = 'NEW'; -- restartable: done rows leave the setTYPE t_ids ISTABLEOF orders.order_id%TYPE;
l_ids t_ids;
BEGINOPEN c;
LOOPFETCH c BULKCOLLECTINTO l_ids LIMIT 50000;
EXITWHEN l_ids.COUNT = 0;
FORALL i IN 1 .. l_ids.COUNT
UPDATE orders SET status = 'PROCESSED'WHERE order_id = l_ids(i);
COMMIT; -- once per 50K rows: ~10K commits instead of 500MENDLOOP;
CLOSE c;
END;
Why:
The COMMIT is a recovery point: on failure, rerun — WHERE status = 'NEW' skips what is already done.
Batch size balances UNDO against commit count. 10K–100K is a sensible range; beyond that — coordinate with the DBA.
Principle: Values go in as binds (USING), not concatenated. Identifiers (table names) go through DBMS_ASSERT.
What breaks at scale: Every concatenated value = new SQL text = new hard parse = new shared pool entry. 100K distinct values = 100K plans in the library cache, latch contention, and eventually ORA-04031. Plus SQL injection for free.
See it run — 100K distinct values
Animation with two lanes. Concatenated values: every value makes a new SQL text, so every execution is a new hard parse and a new plan in the shared pool — 100K values, 100K plans, and eventually ORA-04031. Bind variables: one SQL text for every value — one parse and one plan, reused by every execution.
❌ Concatenated — a new SQL text per value100K plans — eventually ORA-04031
sessionshared pool
values100,000/ 100K
hard parses100,000
plans in the shared pool100,000
✅ Bind variables — one SQL textdone ✓
sessionshared pool
values100,000/ 100K
hard parses1
plans in the shared pool1
❌ Bad
PROCEDURE archive_year (p_year NUMBER, p_status VARCHAR2) ISBEGINEXECUTEIMMEDIATE'INSERT INTO orders_archive_' || p_year ||
' SELECT * FROM orders WHERE status = ''' || p_status || '''' ||
' AND EXTRACT(YEAR FROM order_date) = ' || p_year;
END;
Why:
p_status is concatenated: every value = a hard parse; and p_status := 'X'' OR 1=1--' does whatever it wants.
EXTRACT(YEAR …) on the partition key disables partition pruning — 500M rows scanned instead of 40M.
✅ Recommended
PROCEDURE archive_year (p_year NUMBER, p_status VARCHAR2) IS
l_table VARCHAR2(128) := DBMS_ASSERT.SQL_OBJECT_NAME('orders_archive_' || p_year);
BEGINEXECUTEIMMEDIATE'INSERT INTO ' || l_table ||
' SELECT * FROM orders WHERE status = :1' ||
' AND order_date >= :2 AND order_date < :3'USING p_status,
TO_DATE(p_year || '0101', 'YYYYMMDD'),
TO_DATE((p_year + 1) || '0101', 'YYYYMMDD');
END;
Why:
One SQL text for all years and statuses → one parse, one plan in the shared pool.
DBMS_ASSERT.SQL_OBJECT_NAME raises ORA-44002 if the table does not exist — no way to inject a name.
A date range on the partition key → pruning works.
Principle: Never swallow errors. An expected error — a specific handler. Everything else — log and RAISE.
What breaks at scale:WHEN OTHERS THEN NULL in a loop over 500M rows = 3M orders that failed silently, and nobody knows until the customer calls. And in a nightly job — the job "succeeded".
See it run — 500M rows
Animation with two lanes. WHEN OTHERS THEN NULL: failing orders are swallowed and the loop runs on — over 500M rows, 3M orders fail silently, nobody knows, and the nightly job "succeeded". Log and RAISE: the first unexpected error is written to error_log with its backtrace and raised again, so the job stops and the caller decides.
❌ WHEN OTHERS THEN NULL — errors swalloweddone ✓ — the job "succeeded"
loopprocess_order
orders500,000,000/ 500M
failed silently3,000,000
errors logged0
✅ Log and RAISE — errors surfacestopped: logged with backtrace, raised — the caller decides
loopprocess_order
orders3/ 500M
failed silently0
errors logged1
❌ Bad
BEGINFOR r IN (SELECT order_id, amount FROM orders WHERE status = 'NEW') LOOPBEGIN
process_order(r.order_id, r.amount);
EXCEPTIONWHENOTHERSTHENNULL; -- "so it won't crash". And we never learn about 3M failed rows.END;
ENDLOOP;
END;
Why:
The error, the row that caused it and the stack — all gone. No way to recover.
WHEN OTHERS also catches ORA-01555, ORA-04031, ORA-00060 — infrastructure errors that must stop the job.
Principle: Lock little, lock late, lock briefly. SKIP LOCKED lets several workers run on the same table without colliding.
What breaks at scale:FOR UPDATE on 50M rows followed by hours of processing = every application session touching those rows is stuck. And if two jobs do it — deadlock (ORA-00060) or an endless queue.
See it run — 50M rows
Animation with two lanes; each square is a slice of the rows. FOR UPDATE on everything: one session locks all 50M rows at once, works through them, and releases the locks only with the final COMMIT — everyone who needs one of those rows waits the whole time. SKIP LOCKED batches: 8 sessions each lock a batch of 1,000 rows, work it and commit, so locks live seconds and the sessions never wait on each other.
❌ FOR UPDATE on every row — one sessiondone ✓
1 sessioneveryone who needs a row waits
locked working done, still locked committed
rows50,000,000/ 50M
peak rows locked50,000,000
sessions working1
✅ SKIP LOCKED batches — 8 sessionsdone ✓
8 sessionslocks live seconds, not hours
locked working committed
rows50,000,000/ 50M
peak rows locked8,000
sessions working8
❌ Bad
DECLARECURSOR c ISSELECT order_id FROM orders WHERE status = 'NEW'FORUPDATE;
BEGINFOR r IN c LOOP-- locks all 50M NEW rows at once
process_order(r.order_id); -- long processing: everyone who needs a row waitsUPDATE orders SET status = 'DONE'WHERE CURRENT OF c;
ENDLOOP;
COMMIT; -- the lock is released only here — hours laterEND;
Why:
The lock lasts the whole life of the job. A second worker on the same table waits or fails.
No parallelism possible — all 500M rows on one thread.
✅ Recommended
DECLARETYPE t_ids ISTABLEOF orders.order_id%TYPE;
l_ids t_ids;
BEGINLOOPSELECT order_id BULKCOLLECTINTO l_ids
FROM orders
WHERE status = 'NEW'AND ROWNUM <= 1000
FORUPDATESKIPLOCKED; -- locks one batch only; skips what another worker holdsEXITWHEN l_ids.COUNT = 0;
FOR i IN 1 .. l_ids.COUNT LOOP
process_order(l_ids(i));
ENDLOOP;
FORALL i IN 1 .. l_ids.COUNT
UPDATE orders SET status = 'DONE'WHERE order_id = l_ids(i);
COMMIT; -- the lock lives seconds, not hoursENDLOOP;
END;
Why:
Each batch = a short transaction holding 1,000 locks; the COMMIT releases them immediately.
The same code runs in 8 sessions in parallel: SKIP LOCKED divides the work between them automatically, with no coordination.
Principle: On tens of millions of rows use the database's tools — direct path, parallel, partitions — not a loop.
What breaks at scale: A DELETE of 40M rows = 40M undo + redo records, hours, and indexes maintained row by row. A regular INSERT of 40M = through the buffer cache, block by block. And a single PL/SQL loop over 500M — one thread, 8 hours, while the server has 64 cores.
See it run — archive 40M rows
Animation with two lanes. Row by row: the PL/SQL loop inserts each of 40M rows into the archive and deletes it from orders — 80M single-row statements through the buffer cache, 40M undo + redo records for the DELETE, on one core out of 64. EXCHANGE PARTITION: one DDL statement swaps the whole partition with the archive table — a metadata swap, zero data copied, 40M rows in a millisecond.
❌ Row by row — an INSERT and a DELETE per rowdone ✓
PL/SQL loopbuffer cacheindexes maintained row by row
rows archived40,000,000/ 40M
single-row statements80,000,000
undo + redo records40,000,000
cores busy1/ 64
✅ EXCHANGE PARTITION — a metadata swapdone ✓
sessiondata dictionarymetadata swap — zero data copied
rows archived40,000,000/ 40M
single-row statements0
rows copied0
❌ Bad
-- Archive a year: 40M rows, row by row, plus a DELETE generating 40M undo + redoBEGINFOR r IN (SELECT * FROM orders WHERE order_date < DATE'2025-01-01') LOOPINSERTINTO orders_archive VALUES r;
DELETEFROM orders WHERE order_id = r.order_id;
ENDLOOP;
COMMIT;
END;
Why:
80M single-row statements, through the buffer cache, with full undo. One core works, 63 idle.
The DELETE does not release space — the table stays the same size, with holes.
✅ Recommended
-- a. Archive a whole partition = metadata swap. Zero data copied.ALTERTABLE orders EXCHANGEPARTITION p_2024_12
WITHTABLE orders_archive_2024_12 INCLUDINGINDEXESWITHOUTVALIDATION;
-- b. When copying is unavoidable: direct path + parallel (bypasses buffer cache, minimal undo)ALTERSESSIONENABLEPARALLELDML;
INSERT/*+ APPEND PARALLEL(a, 8) */INTO orders_archive a
SELECT/*+ PARALLEL(o, 8) */ * FROM orders o WHERE o.order_date < DATE'2025-01-01';
COMMIT;
ALTERTABLE orders TRUNCATEPARTITION p_2024_12 UPDATEINDEXES; -- not DELETE-- c. When PL/SQL per row is unavoidable: chunks in parallelBEGIN
DBMS_PARALLEL_EXECUTE.CREATE_TASK('reprice');
DBMS_PARALLEL_EXECUTE.CREATE_CHUNKS_BY_ROWID('reprice', USER, 'ORDERS', TRUE, 100000);
DBMS_PARALLEL_EXECUTE.RUN_TASK('reprice',
'UPDATE orders SET amount = pricing_pkg.reprice(order_id, amount)
WHERE rowid BETWEEN :start_id AND :end_id', DBMS_SQL.NATIVE, parallel_level => 8);
END;
Why:
EXCHANGE PARTITION: 40M rows in a millisecond. This is why orders is partitioned by order_date.
APPEND + PARALLEL: 8 cores writing straight to the datafiles. TRUNCATE PARTITION: DDL, seconds, no undo.
DBMS_PARALLEL_EXECUTE: splits the table into rowid chunks, runs 8 at a time, each chunk with its own COMMIT and resume on failure.
Principle: Every job identifies itself (module/action) and times itself. Without a number — no optimization.
What breaks at scale: "The job takes 4 hours" — but which step? Which SQL? The DBA sees a session in V$SESSION with module = 'SQL*Plus' and anonymous SQL. Optimization becomes guessing, and the guess is usually wrong.
See it run — the nightly job
Animation with two lanes. Anonymous job: load, reprice and archive run one after another while V$SESSION shows only module SQL*Plus — 4 hours, but which step? Instrumented job: each step sets module and action, so V$SESSION shows nightly_batch / load, then nightly_batch / reprice, and every step is timed.
❌ Anonymous — no names, no timings4 hours — but which step?