Efficient PL/SQL for Large Oracle Databases — Course Plan

The one sentence: The code runs in the database — not in the loop.

  • 10 chapters
  • 38 commands in the appendix

GoalTeach PL/SQL developers to write code that behaves well on hundreds of millions of rows — not just on 10,000.
AudienceDevelopers who already write PL/SQL: blocks, cursors, procedures, packages. There is no basics chapter.
VersionOracle 19c and up. Every command is available from 12c.
Structure10 chapters. Each chapter = one principle, a bad example vs. a recommended one, and commands linked to the appendix.
Duration20–30 minutes per chapter. One working day in total.

The Thesis in Three Lines

  1. SQL first. If it can be written as a single SQL statement — it is not PL/SQL.
  2. Bulk when PL/SQL is unavoidable. Read and write in batches, never row by row.
  3. 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.

TableColumnsSize
customerscustomer_id (PK), status, tier, last_order_date5M rows
ordersorder_id (PK), customer_id, order_date, amount, status500M rows, partitioned by order_date (monthly: p_2024_12, …)
orders_archivesame as ordersarchive
error_loglogged_at, ctx, err_code, err_msg, backtraceerror 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.

Chapter List

  1. SQL First — one statement instead of a loop
  2. Bulk — read and write in batches
  3. Functions Inside SQL — minimize context switches
  4. In-Memory Lookup — load once, don't query in a loop
  5. COMMIT Strategy — never COMMIT per row
  6. Dynamic SQL — bind, don't concatenate
  7. Exceptions — don't hide errors
  8. Locking — short transactions, never wait on a lock
  9. Huge Tables — direct path, parallel, chunks
  10. Measure — don't guess

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 ✓

customers5,000,000/ 5M
SQL statements 10,000,000
index probes 10,000,000

MERGE — one statementdone ✓

customers5,000,000/ 5M
SQL statements 1
index probes 0

Bad

DECLARE
  v_cnt NUMBER;
BEGIN
  FOR r IN (SELECT customer_id, MAX(order_date) AS last_dt
            FROM   orders GROUP BY customer_id) LOOP
    SELECT COUNT(*) INTO v_cnt
    FROM   customers WHERE customer_id = r.customer_id;
    IF v_cnt > 0 THEN
      UPDATE customers
      SET    last_order_date = r.last_dt
      WHERE  customer_id = r.customer_id;
    END IF;
  END LOOP;
  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.
MERGE INTO customers c
USING (SELECT customer_id, MAX(order_date) AS last_dt
       FROM   orders
       GROUP  BY customer_id) o
ON    (c.customer_id = o.customer_id)
WHEN MATCHED THEN
  UPDATE SET c.last_order_date = o.last_dt;

-- Existence check: EXISTS stops at the first row found
SELECT CASE WHEN EXISTS (SELECT 1 FROM orders WHERE customer_id = :id)
            THEN 1 ELSE 0 END
INTO   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.

Rule of thumb: If it can be written as one SQL statement — it is not PL/SQL.

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 ✓

rows500,000,000/ 500M
context switches 1,000,000,000

Bulk — 10,000 rows per round tripdone ✓

rows500,000,000/ 500M
context switches 100,000

Bad

DECLARE
  CURSOR c IS SELECT order_id, amount FROM orders WHERE status = 'NEW';
  r c%ROWTYPE;
BEGIN
  OPEN c;
  LOOP
    FETCH c INTO r;
    EXIT WHEN c%NOTFOUND;
    UPDATE orders
    SET    amount = pricing_pkg.reprice(r.order_id, r.amount)
    WHERE  order_id = r.order_id;
  END LOOP;
  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).
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;
    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);
    END LOOP;
    FORALL i IN 1 .. l_rows.COUNT                         -- one DML for the whole batch
      UPDATE orders SET amount = l_rows(i).amount
      WHERE  order_id = l_rows(i).order_id;
    COMMIT;
  END LOOP;
  CLOSE c;
END;

Why:

  • 10,000 rows = 2 switches (fetch + FORALL) instead of 20,000.
  • LIMIT keeps PGA flat; without it — 500M rows in session memory.

Rule of thumb: PL/SQL touches data only in batches — BULK COLLECT LIMIT in, FORALL out.

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 ✓

orders500,000,000/ 500M
function calls 500,000,000
hidden queries 500,000,000

JOIN — the logic in SQLdone ✓

orders500,000,000/ 500M
function calls 0
hidden queries 0

Bad

CREATE OR REPLACE FUNCTION is_vip (p_customer_id NUMBER) RETURN NUMBER IS
  v_tier customers.tier%TYPE;
BEGIN
  SELECT tier INTO v_tier FROM customers WHERE customer_id = p_customer_id;
  RETURN CASE WHEN 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.
-- 1. Logic in SQL: a JOIN instead of a function
SELECT 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 calls
CREATE OR REPLACE FUNCTION vat_amount (p_amount NUMBER) RETURN NUMBER
  DETERMINISTIC
IS
  PRAGMA UDF;
BEGIN
  RETURN 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.

Rule of thumb: A function in WHERE = a hidden loop. JOIN/CASE first; a function only when there is no alternative — then DETERMINISTIC + PRAGMA UDF.

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 ✓

orders500,000,000/ 500M
queries against customers 500,000,000

Load once, look up in memorydone ✓

orders500,000,000/ 500M
queries against customers 1

Bad

DECLARE
  v_tier customers.tier%TYPE;
BEGIN
  FOR r IN (SELECT order_id, customer_id, amount FROM orders WHERE status = 'NEW') LOOP
    SELECT tier INTO v_tier
    FROM   customers WHERE customer_id = r.customer_id;   -- 500M times
    IF v_tier = 'VIP' THEN
      apply_discount(r.order_id, r.amount);
    END IF;
  END LOOP;
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.
DECLARE
  TYPE t_tier_map IS TABLE OF customers.tier%TYPE INDEX BY PLS_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;
  END LOOP;

  FOR r IN (SELECT order_id, customer_id, amount FROM orders WHERE status = 'NEW') LOOP
    IF l_tier.EXISTS(r.customer_id) THEN                  -- O(1), no SQL
      apply_discount(r.order_id, r.amount);
    END IF;
  END LOOP;                                               -- (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.

Rule of thumb: A small lookup table read in a loop — into memory once. A large one — JOIN (Chapter 1).

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

rows committed250,000,000/ 500M
commits 250,000,000
log file sync waits 250,000,000

COMMIT per 50,000-row batchdone ✓

rows committed500,000,000/ 500M
commits 10,000
log file sync waits 10,000

Bad

BEGIN
  FOR r IN (SELECT order_id FROM orders WHERE status = 'NEW') LOOP
    UPDATE orders SET status = 'PROCESSED' WHERE order_id = r.order_id;
    COMMIT;      -- 500M times: log file sync per row + ORA-01555 on the open cursor
  END LOOP;
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".
DECLARE
  CURSOR c IS SELECT order_id FROM orders WHERE status = 'NEW';   -- restartable: done rows leave the set
  TYPE t_ids IS TABLE OF orders.order_id%TYPE;
  l_ids t_ids;
BEGIN
  OPEN c;
  LOOP
    FETCH c BULK COLLECT INTO l_ids LIMIT 50000;
    EXIT WHEN 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 500M
  END LOOP;
  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.

Commands:

Rule of thumb: COMMIT per batch, never per row. A batch = a unit of work that can be repeated.

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

values100,000/ 100K
hard parses 100,000
plans in the shared pool 100,000

Bind variables — one SQL textdone ✓

values100,000/ 100K
hard parses 1
plans in the shared pool 1

Bad

PROCEDURE archive_year (p_year NUMBER, p_status VARCHAR2) IS
BEGIN
  EXECUTE IMMEDIATE
    '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.
PROCEDURE archive_year (p_year NUMBER, p_status VARCHAR2) IS
  l_table VARCHAR2(128) := DBMS_ASSERT.SQL_OBJECT_NAME('orders_archive_' || p_year);
BEGIN
  EXECUTE IMMEDIATE
    '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.

Rule of thumb: Values → bind (USING). Identifiers → DBMS_ASSERT. A value concatenated into a SQL string = a bug.

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"

orders500,000,000/ 500M
failed silently 3,000,000
errors logged 0

Log and RAISE — errors surfacestopped: logged with backtrace, raised — the caller decides

orders3/ 500M
failed silently 0
errors logged 1

Bad

BEGIN
  FOR r IN (SELECT order_id, amount FROM orders WHERE status = 'NEW') LOOP
    BEGIN
      process_order(r.order_id, r.amount);
    EXCEPTION
      WHEN OTHERS THEN NULL;   -- "so it won't crash". And we never learn about 3M failed rows.
    END;
  END LOOP;
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.
CREATE OR REPLACE PROCEDURE log_error (
  p_ctx VARCHAR2, p_code NUMBER, p_msg VARCHAR2, p_backtrace VARCHAR2 DEFAULT NULL) IS
  PRAGMA AUTONOMOUS_TRANSACTION;                          -- the log survives even if the job rolls back
BEGIN
  INSERT INTO error_log (logged_at, ctx, err_code, err_msg, backtrace)
  VALUES (SYSTIMESTAMP, p_ctx, p_code, p_msg, p_backtrace);
  COMMIT;
END;
/
BEGIN
  process_order(p_order_id, p_amount);
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    NULL;                                                 -- expected case, decided deliberately
  WHEN OTHERS THEN
    log_error('process_order ' || p_order_id, SQLCODE, SQLERRM,
              DBMS_UTILITY.FORMAT_ERROR_BACKTRACE);
    RAISE;                                                -- don't swallow — the caller decides
END;

Why:

  • An expected error is handled by name, with an explicit business decision. Everything else — logged with the exact line (backtrace) and re-raised.
  • In bulk processing (Chapter 2) — SAVE EXCEPTIONS gives the same thing at batch level.

Rule of thumb: WHEN OTHERS without RAISE or a log = a bug. Expected error — specific handler; everything else — log and RAISE.

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 ✓

rows50,000,000/ 50M
peak rows locked 50,000,000
sessions working 1

SKIP LOCKED batches — 8 sessionsdone ✓

rows50,000,000/ 50M
peak rows locked 8,000
sessions working 8

Bad

DECLARE
  CURSOR c IS SELECT order_id FROM orders WHERE status = 'NEW' FOR UPDATE;
BEGIN
  FOR r IN c LOOP                          -- locks all 50M NEW rows at once
    process_order(r.order_id);             -- long processing: everyone who needs a row waits
    UPDATE orders SET status = 'DONE' WHERE CURRENT OF c;
  END LOOP;
  COMMIT;                                  -- the lock is released only here — hours later
END;

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.
DECLARE
  TYPE t_ids IS TABLE OF orders.order_id%TYPE;
  l_ids t_ids;
BEGIN
  LOOP
    SELECT order_id BULK COLLECT INTO l_ids
    FROM   orders
    WHERE  status = 'NEW' AND ROWNUM <= 1000
    FOR UPDATE SKIP LOCKED;                -- locks one batch only; skips what another worker holds
    EXIT WHEN l_ids.COUNT = 0;
    FOR i IN 1 .. l_ids.COUNT LOOP
      process_order(l_ids(i));
    END LOOP;
    FORALL i IN 1 .. l_ids.COUNT
      UPDATE orders SET status = 'DONE' WHERE order_id = l_ids(i);
    COMMIT;                                -- the lock lives seconds, not hours
  END LOOP;
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.

Commands:

Rule of thumb: Lock little, lock late, lock briefly. SKIP LOCKED = several workers on one table without collisions.

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 ✓

rows archived40,000,000/ 40M
single-row statements 80,000,000
undo + redo records 40,000,000
cores busy 1/ 64

EXCHANGE PARTITION — a metadata swapdone ✓

rows archived40,000,000/ 40M
single-row statements 0
rows copied 0

Bad

-- Archive a year: 40M rows, row by row, plus a DELETE generating 40M undo + redo
BEGIN
  FOR r IN (SELECT * FROM orders WHERE order_date < DATE '2025-01-01') LOOP
    INSERT INTO orders_archive VALUES r;
    DELETE FROM orders WHERE order_id = r.order_id;
  END LOOP;
  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.
-- a. Archive a whole partition = metadata swap. Zero data copied.
ALTER TABLE orders EXCHANGE PARTITION p_2024_12
  WITH TABLE orders_archive_2024_12 INCLUDING INDEXES WITHOUT VALIDATION;

-- b. When copying is unavoidable: direct path + parallel (bypasses buffer cache, minimal undo)
ALTER SESSION ENABLE PARALLEL DML;
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;
ALTER TABLE orders TRUNCATE PARTITION p_2024_12 UPDATE INDEXES;   -- not DELETE

-- c. When PL/SQL per row is unavoidable: chunks in parallel
BEGIN
  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.

Rule of thumb: Millions of rows: not DELETE — TRUNCATE/EXCHANGE; not a plain INSERT — APPEND + PARALLEL; not one loop — chunks.

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?

steps3/ 3
steps timed 0

module / action and a timer per stepdone ✓ — you know where 3.5 of the 4 hours go

steps2/ 2
steps timed 2

Bad

-- "The job takes 4 hours." Where? No idea.
BEGIN
  load_orders;
  reprice_orders;
  archive_orders;
END;

Why:

  • No steps, no timings, no identification in V$SQL/AWR. Every SQL of the job looks like any other SQL.
DECLARE
  t0 PLS_INTEGER;
BEGIN
  DBMS_APPLICATION_INFO.SET_MODULE('nightly_batch', 'load');
  t0 := DBMS_UTILITY.GET_TIME;
  load_orders;
  DBMS_OUTPUT.PUT_LINE('load: ' || (DBMS_UTILITY.GET_TIME - t0) / 100 || 's');

  DBMS_APPLICATION_INFO.SET_ACTION('reprice');
  t0 := DBMS_UTILITY.GET_TIME;
  reprice_orders;
  DBMS_OUTPUT.PUT_LINE('reprice: ' || (DBMS_UTILITY.GET_TIME - t0) / 100 || 's');

  DBMS_APPLICATION_INFO.SET_MODULE(NULL, NULL);
END;
/
-- Now every SQL of the job is tagged, and you can ask "what is heaviest":
SELECT sql_id, executions, elapsed_time/1e6 AS sec, SUBSTR(sql_text, 1, 60) AS txt
FROM   v$sql WHERE module = 'nightly_batch'
ORDER  BY elapsed_time DESC FETCH FIRST 5 ROWS ONLY;

Why:

  • A time per step → you know where 3.5 of the 4 hours go. (In production — a log table, not DBMS_OUTPUT.)
  • module/action show up in V$SESSION, V$SQL, AWR, ASH → the DBA and you talk about the same thing.
  • Next step: DBMS_HPROF for a per-function profile (deep dive), DBMS_XPLAN.DISPLAY_CURSOR on the heavy SQL (deep dive).

Rule of thumb: Every job identifies and times itself. Without a number there is no optimization, only guessing.

TopicWhy not
TriggersNot a performance tool. On huge tables they mostly cost — and hide logic.
Object Types, Pipelined functionsNiche. Useful for complex ETL, not for the core of this course.
Java / External proceduresOutside PL/SQL.
Indexing strategy, SQL tuningA separate course. Here we assume the SQL is already tuned and deal with what wraps it.
Materialized Views, In-Memory, SQL Result CacheDBA infrastructure solutions, not PL/SQL code.
PLSQL_OPTIMIZE_LEVEL, NATIVE compilationSingle-digit percent gains. Irrelevant when the problem is row-by-row.