Skip to content
Home Database PL/SQL Control Structures — IF, LOOP, WHILE

PL/SQL Control Structures — IF, LOOP, WHILE

Where developers are forged. · Structured learning · Free forever.
📍 Part of: PL/SQL → Topic 3 of 27
Master PL/SQL control structures including IF-THEN-ELSE, basic LOOP, FOR LOOP, and WHILE LOOP.
🧑‍💻 Beginner-friendly — no prior Database experience needed
In this tutorial, you'll learn
Master PL/SQL control structures including IF-THEN-ELSE, basic LOOP, FOR LOOP, and WHILE LOOP.
  • PL/SQL control structures exist to fill the gap SQL cannot — conditional branching on runtime state, iterative processing where each row's result affects the next, and error recovery that continues rather than aborts. Reach for them when set-based SQL genuinely cannot express the logic, not by default.
  • IF-THEN-ELSIF is evaluated top-down with short-circuit — first TRUE branch wins, everything else is skipped. NULL in any standard comparison returns NULL, not TRUE. IS NULL and IS NOT NULL are the only correct operators for null checks. No exceptions.
  • ELSE branches are not optional in batch processing — they are your audit point for unexpected states. A missing ELSE means unrecognized data silently passes through. Add an ELSE that writes to a persistent audit table in every batch IF block.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • PL/SQL control structures add procedural decision-making and repetition to Oracle's declarative SQL engine
  • IF-THEN-ELSIF handles branching; CASE selects from mutually exclusive options
  • Three loop types: BASIC (run at least once), WHILE (check first), FOR (known range or cursor)
  • PL/SQL uses Three-Valued Logic — NULL compared with = always returns NULL, never TRUE
  • FORALL and set-based SQL are 100x faster than RBAR loops for bulk DML operations
  • Forgetting EXIT WHEN or cursor cleanup causes ORA-01000 and runaway sessions
🚨 START HERE
PL/SQL Control Structure Quick Debug Reference
Immediate actions for common PL/SQL loop and conditional issues in production
🟡Infinite loop consuming all PGA memory
Immediate ActionIdentify the runaway session: SELECT sid, serial#, status, sql_id FROM V$SESSION WHERE status = 'ACTIVE' AND username IS NOT NULL
Commands
ALTER SYSTEM KILL SESSION '<sid>,<serial#>' IMMEDIATE
SELECT sql_id, sql_text FROM V$SQL WHERE sql_id = (SELECT sql_id FROM V$SESSION WHERE sid = <sid>)
Fix NowAdd EXIT WHEN v_counter > max_iterations as a safety ceiling inside every BASIC LOOP. Never rely solely on a business condition for exit.
🟡NULL comparison returning unexpected results
Immediate ActionAudit the procedure for any = NULL or <> NULL comparisons — grep the source in ALL_SOURCE: SELECT line, text FROM ALL_SOURCE WHERE owner = 'SCHEMA' AND name = 'PROC_NAME' AND text LIKE '%= NULL%'
Commands
SELECT COUNT(*) FROM target_table WHERE status IS NULL
DBMS_OUTPUT.PUT_LINE('Value: ' || NVL(TO_CHAR(v_var), 'IS NULL'))
Fix NowReplace every = NULL with IS NULL and every <> NULL with IS NOT NULL. Add an ELSE branch that writes to an audit table so unmatched states are visible.
🟡ORA-01000 maximum open cursors
Immediate ActionFind which SQL statements have open cursors in the affected session: SELECT sql_text, COUNT(*) FROM V$OPEN_CURSOR WHERE sid = <sid> GROUP BY sql_text ORDER BY 2 DESC
Commands
ALTER SYSTEM SET open_cursors = 1000 SCOPE = BOTH
SELECT sql_text, count(*) FROM V$OPEN_CURSOR GROUP BY sql_text ORDER BY 2 DESC
Fix NowConvert explicit cursor loops to Cursor FOR LOOP. For any remaining explicit cursors, add CLOSE cursor_name in both the normal execution path and the EXCEPTION block.
🟡RBAR loop causing table lock contention
Immediate ActionConfirm TM locks are accumulating: SELECT sid, type, lmode, request, ctime FROM V$LOCK WHERE type = 'TM' ORDER BY ctime DESC
Commands
SELECT sql_id, executions, ROUND(elapsed_time/1000000, 2) elapsed_secs FROM V$SQL WHERE sql_id = '<sql_id>'
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('<sql_id>', NULL, 'ALLSTATS LAST'))
Fix NowReplace the loop with FORALL for DML operations or a single set-based UPDATE/INSERT. Confirm the rewrite with an explain plan before deploying.
Production IncidentNULL Comparison in IF Block Silently Skipped Invoice Reconciliation for 12,000 RecordsAn overnight batch job using IF v_status = NULL failed to process invoices where the status column was NULL, causing a £2.3M reconciliation discrepancy discovered during month-end audit.
SymptomMonth-end reconciliation showed £2.3M in unprocessed invoices. The batch job log showed zero errors and a clean completion status. Nobody looked twice at it until a manual audit query revealed 12,000 records with a NULL status column had never been touched — no update, no log entry, nothing.
AssumptionThe developer wrote IF v_status = NULL THEN expecting it to evaluate to TRUE when the variable held no value. The reasoning was intuitive: if the variable is empty, the condition should match. In PL/SQL's Three-Valued Logic, that intuition is wrong in a way that leaves no trace — the condition evaluates to NULL, not TRUE, so the entire branch is silently skipped on every row where status was NULL.
Root causePL/SQL inherits SQL's Three-Valued Logic: any comparison involving NULL using standard equality or relational operators (=, <>, <, >) returns NULL rather than TRUE or FALSE. The IF statement only executes its block when the condition is TRUE. NULL is not TRUE. So IF v_status = NULL — regardless of what v_status actually contains — never fires. There was no error, no warning, no log entry. The batch job processed every non-NULL record correctly and silently skipped every NULL-status record. The only indication something was wrong was a £2.3M gap in the reconciliation figures three weeks later.
FixEvery = NULL comparison was replaced with IS NULL. Every <> NULL comparison became IS NOT NULL. An ELSE branch was added to each IF block in the batch procedure, writing an audit record to a dedicated exception table whenever a record did not match any expected state. A post-batch verification query was added to the job scheduler: it counts processed versus unprocessed records and raises an alert if the gap exceeds a configurable threshold. The NULL-status records were identified, manually reviewed, corrected, and reprocessed.
Key Lesson
Never use = NULL in PL/SQL — always use IS NULL for NULL comparisonsThree-Valued Logic means NULL comparisons silently evaluate to NULL, not TRUE — no error is raisedAdd ELSE branches with audit logging to every IF block in batch jobs — silent skipping is worse than a visible failurePost-batch verification queries that compare expected versus actual record counts catch silent logic failures before they compound over weeks
Production Debug GuideCommon symptoms and immediate actions for production PL/SQL issues
ORA-01000: maximum open cursors exceeded during batch processingFind unclosed explicit cursors — each OPEN without a matching CLOSE consumes a cursor slot for the life of the session. Query V$OPEN_CURSOR for the affected SID to see which SQL statements have open cursors. The fastest fix is converting explicit cursor loops to Cursor FOR LOOP, which handles open, fetch, and close implicitly — even when an exception fires mid-loop.
Batch job runs indefinitely with no errors in logThe most likely cause is a BASIC LOOP missing its EXIT WHEN condition or a counter that is never incremented. Query V$SESSION filtered to ACTIVE status and cross-reference with V$SQL to find the executing statement. Once identified, kill the session with ALTER SYSTEM KILL SESSION and fix the loop before restarting. Add a safety ceiling — EXIT WHEN v_counter > max_allowed_iterations — as a belt-and-suspenders guard.
IF block never executes despite the variable appearing to be setThe variable is almost certainly NULL. Add DBMS_OUTPUT.PUT_LINE('Value: ' || NVL(TO_CHAR(v_var), 'IS NULL')) immediately before the IF statement to confirm. Replace any = NULL or <> NULL comparisons with IS NULL and IS NOT NULL. NULL propagates silently through string concatenation too — NVL is essential for diagnostic output.
Batch processing 10x or more slower than expectedConfirm RBAR processing is the cause: check whether the loop body contains individual INSERT, UPDATE, or DELETE statements firing once per row. Capture the SQL ID from V$SESSION during execution and run DBMS_XPLAN.DISPLAY_CURSOR to inspect the plan. If you see a high executions count against a simple single-row DML, the loop is the bottleneck — rewrite as FORALL or a single set-based SQL statement.
ORA-06502: PL/SQL numeric or value error in loop bodyA variable inside the loop received a value wider than its declared size, or a type mismatch occurred mid-iteration. The error aborts the loop on the offending row. Wrap the loop body in a BEGIN...EXCEPTION WHEN OTHERS block: log the failing row's primary key and SQLERRM to an error table, then let the loop continue. Investigate the logged rows after the batch completes rather than restarting from scratch.

Standard SQL is declarative — you describe the result you want and the optimizer figures out how to get there. That model works brilliantly for set-based operations, but it breaks down the moment your logic needs to branch on a runtime value, iterate until a condition is met, or recover from a failure mid-stream and continue processing. That is exactly the gap PL/SQL control structures fill.

In production, the stakes around this are not theoretical. A poorly structured loop processing 500,000 rows one at a time generates context switches between the PL/SQL and SQL engines on every single iteration — each one carrying the overhead of a round trip. The redo log swells. Row-level locks accumulate. PGA memory climbs. A batch job that should complete in under a minute ends up holding locks for thirty. The same operation expressed as a single FORALL or a well-structured UPDATE runs entirely inside the SQL engine with an optimized execution plan and no context-switching penalty.

But set-based SQL has its own limits. When each row's outcome determines how the next row should be processed, when business rules span multiple dependent queries, when you need to log partial failures and continue rather than abort — that is where procedural control structures earn their place. The judgment call between reaching for a loop versus restructuring the problem as a single SQL statement is one of the clearest signals separating a mid-level Oracle developer from a senior one.

This guide covers the mechanics, the production patterns, the failure modes, and the performance trade-offs. By the end you will know not just how each structure works, but when to use it, when to avoid it, and what to check first when something goes wrong at 2 a.m.

Conditional Logic: The IF-THEN-ELSIF Branching Model

SQL's declarative model handles set operations elegantly, but it was never designed for branching on runtime state. The moment you need to execute a completely different code path based on a value fetched from the database — not just select a different column value, but run different procedures, apply different validation rules, or write to different tables — you need procedural conditional logic. IF-THEN-ELSIF is how PL/SQL expresses that.

The evaluation model is straightforward: conditions are assessed top-down and the first branch whose condition evaluates to TRUE executes. Every subsequent branch is skipped entirely. This short-circuit behaviour is not just an optimisation — it is the mechanism. It means condition ordering matters. If you place the most expensive condition first and it is rarely TRUE, you are paying that cost on every evaluation. Put your cheapest, most commonly matched condition at the top.

The ELSE clause is where many production batch jobs have quietly failed. Without it, any input state that matches no ELSIF condition is silently ignored. In an interactive application, a missed branch is a minor UX issue. In an overnight batch job processing 300,000 invoices, it means a subset of records is never touched and nobody finds out until month-end reconciliation. The ELSE branch should always be present in batch processing, and it should always write to an audit table — not just to DBMS_OUTPUT, which is lost the moment the session ends.

Null handling is the sharpest edge in this model. PL/SQL inherits Three-Valued Logic from SQL: TRUE, FALSE, and NULL. Any comparison involving NULL using a standard operator returns NULL, not FALSE. IF v_status = NULL does not evaluate to TRUE when v_status is NULL — it evaluates to NULL, which is not TRUE, so the block never fires. This is not an edge case. It is how the language works, and it catches experienced developers who switch from other languages where null comparisons behave differently. The fix is always IS NULL and IS NOT NULL — no exceptions.

io/thecodeforge/plsql/ControlFlowExample.sql · SQL
12345678910111213141516171819202122232425262728293031323334353637383940
-- io.thecodeforge: Conditional branching with proper NULL handling and audit trail
DECLARE
    v_counter    NUMBER        := 1;
    v_threshold  CONSTANT NUMBER := 5;
    v_status     VARCHAR2(20);
    v_raw_input  VARCHAR2(20)  := NULL;  -- Simulating a NULL-status record
BEGIN
    -- Demonstrate correct NULL handling — IS NULL, never = NULL
    IF v_raw_input IS NULL THEN
        v_status := 'PENDING_REVIEW';
        -- In production: INSERT INTO batch_audit (record_key, reason, logged_at)
        --                VALUES (v_id, 'NULL status on intake', SYSTIMESTAMP);
        DBMS_OUTPUT.PUT_LINE('NULL input detected — flagged for review');
    ELSIF v_raw_input = 'APPROVED' THEN
        v_status := 'PROCESSING';
    ELSIF v_raw_input IN ('REJECTED', 'CANCELLED') THEN
        v_status := 'CLOSED';
    ELSE
        -- Catch-all: unexpected states must never silently pass through
        v_status := 'UNKNOWN';
        DBMS_OUTPUT.PUT_LINE('WARNING: Unexpected status value — ' ||
                             NVL(v_raw_input, 'IS NULL'));
    END IF;

    -- IF-THEN-ELSIF driving a WHILE LOOP — realistic batch pattern
    WHILE v_counter <= v_threshold LOOP
        IF v_counter < 3 THEN
            v_status := 'INITIALIZING';
        ELSIF v_counter BETWEEN 3 AND 4 THEN
            v_status := 'PROCESSING';
        ELSE
            v_status := 'FINALIZING';
        END IF;

        DBMS_OUTPUT.PUT_LINE(
            'Iteration ' || v_counter || ' | Status: ' || v_status
        );
        v_counter := v_counter + 1;
    END LOOP;
END;
▶ Output
NULL input detected — flagged for review
Iteration 1 | Status: INITIALIZING
Iteration 2 | Status: INITIALIZING
Iteration 3 | Status: PROCESSING
Iteration 4 | Status: PROCESSING
Iteration 5 | Status: FINALIZING
Mental Model
Key Insight:
Three-Valued Logic is not a quirk — it is the contract. NULL in any comparison returns NULL, and NULL is not TRUE. Design for it explicitly.
  • Conditions evaluated top-down — first TRUE match wins, remainder skipped entirely
  • NULL compared with any standard operator returns NULL, never TRUE — use IS NULL without exception
  • ELSE branch is not optional in batch processing — it is your catch-all audit point
  • Order conditions by likelihood or cost — cheapest and most common first reduces unnecessary evaluation
  • Short-circuit applies to AND/OR chains too — if A is FALSE in A AND B, B is never evaluated
📊 Production Insight
The £2.3M reconciliation gap described in the incident above came down to three characters: '= NULL' instead of 'IS NULL'. No exception was raised. The batch job reported success. The only evidence something was wrong was 12,000 rows sitting untouched in a table that should have been fully processed.
Two rules emerged from that post-mortem that now sit in the team's code review checklist: first, grep every new batch procedure for '= NULL' before it ships. Second, every IF block in a batch job must have an ELSE branch that writes to an audit table — not DBMS_OUTPUT, which evaporates when the session ends, but a persistent record with the row key, the unexpected value, and a timestamp.
🎯 Key Takeaway
IF-THEN-ELSIF evaluates top-down with short-circuit — first TRUE branch executes, rest are skipped regardless of what they contain.
Three-Valued Logic is not negotiable: NULL compared with = returns NULL, not TRUE. IS NULL is the only correct operator.
Every IF block in a batch procedure needs an ELSE branch that writes to a persistent audit table. Silent skipping in batch jobs always surfaces at the worst possible moment.
Choosing the Right Conditional Structure
IfEvaluating one variable against multiple discrete known values
UseUse CASE expression or CASE statement — more readable than a long ELSIF chain and easier for the optimizer to reason about
IfComplex conditions combining multiple variables, ranges, or function calls
UseUse IF-THEN-ELSIF — CASE cannot express compound multi-variable conditions cleanly
IfChecking whether a variable has no value before processing it
UseUse IF variable IS NULL — never = NULL under any circumstances
IfOne branch calls an expensive function that should only run when necessary
UseUse AND/OR short-circuit in the IF condition — place the cheap guard condition first so the expensive call is skipped when the guard fails
IfAssigning a value based on a condition inline within a SQL statement
UseUse CASE expression — it returns a value and works inside SELECT, UPDATE SET, and PL/SQL assignment without a separate IF block

Iterative Logic: Master the Three Loop Types

The choice of loop type is a correctness decision before it is a performance decision. Each of the three loop types in PL/SQL encodes a specific contract about when the exit condition is evaluated, who manages the counter, and what happens to open cursors when the loop terminates.

The BASIC LOOP executes its body at least once before evaluating the EXIT WHEN condition. This is the right choice when you need to perform an action — read from a queue, generate a sequence value, attempt a connection — and then decide whether to continue. The risk is the infinite loop: if EXIT WHEN is omitted or the condition can never become TRUE, the loop runs until the session is killed or the server runs out of PGA memory. Always add a safety ceiling alongside the business exit condition.

The WHILE LOOP evaluates its condition before the first iteration. If the condition is false going in, the body never executes. This is the right model when you are processing a queue, a file, or an external resource that might already be empty. The discipline required here is ensuring the condition variable is actually modified inside the loop body — a WHILE that checks a variable nobody updates is an infinite loop waiting to happen.

The FOR LOOP is the safest of the three for the simple reason that you cannot accidentally create an infinite loop with it. The bounds are fixed at entry, the counter is implicitly declared and incremented, and the loop exits cleanly when the upper bound is reached or the cursor is exhausted. The Cursor FOR LOOP extends this safety to resource management: the cursor is opened, each row fetched into the loop record variable, and the cursor closed on exit — even if an unhandled exception fires mid-loop. This is the single most effective way to prevent ORA-01000 in production code.

The performance conversation is separate from the loop type conversation. RBAR — Row-By-Agonizing-Row — is the pattern of performing one DML operation per loop iteration when a single SQL statement or FORALL could handle the entire dataset. The cost is context switching: every transition between the PL/SQL engine and the SQL engine has overhead. For 500,000 rows, that overhead accumulates into minutes. FORALL sends the entire collection of DML operations to the SQL engine in one call. The throughput difference is not a small margin — benchmarks consistently show 50x to 100x improvement for pure DML workloads. If the loop body is a single INSERT, UPDATE, or DELETE, rewrite it as FORALL before it ships.

io/thecodeforge/plsql/LoopPatterns.sql · SQL
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
-- io.thecodeforge: Production loop patterns — safe cursors, bulk DML, and exception resilience
DECLARE
    -- Type declarations for BULK COLLECT and FORALL pattern
    TYPE t_invoice_ids  IS TABLE OF invoices.invoice_id%TYPE INDEX BY PLS_INTEGER;
    TYPE t_amounts      IS TABLE OF invoices.amount%TYPE     INDEX BY PLS_INTEGER;

    v_ids       t_invoice_ids;
    v_amounts   t_amounts;
    v_idx       PLS_INTEGER := 1;
    v_max       CONSTANT PLS_INTEGER := 3;

    -- Simulated error table insert (replace with your audit table)
    PROCEDURE log_error(p_id IN NUMBER, p_msg IN VARCHAR2) IS
        PRAGMA AUTONOMOUS_TRANSACTION;
    BEGIN
        DBMS_OUTPUT.PUT_LINE('ERROR LOG | id=' || p_id || ' | ' || p_msg);
        -- INSERT INTO batch_errors (record_id, error_msg, logged_at)
        -- VALUES (p_id, p_msg, SYSTIMESTAMP);
        COMMIT;
    END;
BEGIN
    -- ---------------------------------------------------------------
    -- Pattern 1: Numeric FOR LOOP — counter implicit, bounds fixed
    -- Use when: known iteration count, no cursor involved
    -- ---------------------------------------------------------------
    DBMS_OUTPUT.PUT_LINE('--- Numeric FOR LOOP ---');
    FOR i IN 1..v_max LOOP
        DBMS_OUTPUT.PUT_LINE('Iteration: ' || i);
    END LOOP;

    -- ---------------------------------------------------------------
    -- Pattern 2: BASIC LOOP with safety ceiling
    -- Use when: must execute at least once before checking exit
    -- ---------------------------------------------------------------
    DBMS_OUTPUT.PUT_LINE('--- BASIC LOOP with safety ceiling ---');
    LOOP
        DBMS_OUTPUT.PUT_LINE('Basic loop pass: ' || v_idx);
        v_idx := v_idx + 1;
        EXIT WHEN v_idx > v_max;           -- Business exit condition
        -- EXIT WHEN v_idx > 10000;        -- Safety ceiling (always add this)
    END LOOP;

    -- ---------------------------------------------------------------
    -- Pattern 3: Cursor FOR LOOP — open/fetch/close all implicit
    -- Use when: iterating over a query result; safest for cursors
    -- ---------------------------------------------------------------
    DBMS_OUTPUT.PUT_LINE('--- Cursor FOR LOOP ---');
    FOR rec IN (
        SELECT level AS invoice_id,
               ROUND(DBMS_RANDOM.VALUE(100, 9999), 2) AS amount
        FROM   dual
        CONNECT BY level <= v_max
    ) LOOP
        BEGIN
            IF rec.amount IS NULL THEN
                log_error(rec.invoice_id, 'NULL amount — skipped');
                CONTINUE;   -- Skip this row, process the rest
            END IF;
            DBMS_OUTPUT.PUT_LINE(
                'Invoice ' || rec.invoice_id ||
                ' | Amount: ' || rec.amount
            );
        EXCEPTION
            WHEN OTHERS THEN
                log_error(rec.invoice_id, SQLERRM);
                -- Loop continues to next row automatically
        END;
    END LOOP;

    -- ---------------------------------------------------------------
    -- Pattern 4: FORALL — bulk DML, no per-row context switching
    -- Use when: loop body is a single INSERT/UPDATE/DELETE
    -- ---------------------------------------------------------------
    DBMS_OUTPUT.PUT_LINE('--- FORALL bulk DML pattern ---');
    -- Populate collections (in production: BULK COLLECT INTO from a query)
    v_ids(1) := 1001; v_amounts(1) := 500.00;
    v_ids(2) := 1002; v_amounts(2) := 1250.75;
    v_ids(3) := 1003; v_amounts(3) := 875.50;

    -- Single round trip to SQL engine for all three rows
    -- FORALL i IN 1..v_ids.COUNT
    --     UPDATE invoices
    --     SET    amount = v_amounts(i), updated_at = SYSTIMESTAMP
    --     WHERE  invoice_id = v_ids(i);

    DBMS_OUTPUT.PUT_LINE(
        'FORALL would process ' || v_ids.COUNT || ' rows in one SQL engine call'
    );
END;
▶ Output
--- Numeric FOR LOOP ---
Iteration: 1
Iteration: 2
Iteration: 3
--- BASIC LOOP with safety ceiling ---
Basic loop pass: 1
Basic loop pass: 2
Basic loop pass: 3
--- Cursor FOR LOOP ---
Invoice 1 | Amount: 4823.17
Invoice 2 | Amount: 312.94
Invoice 3 | Amount: 7651.08
--- FORALL bulk DML pattern ---
FORALL would process 3 rows in one SQL engine call
⚠ Watch Out:
The most expensive mistake in PL/SQL loop design is not the wrong loop type — it is using any loop at all when a set-based SQL statement would do the job. Before writing a loop that performs DML, ask: could a single UPDATE, INSERT...SELECT, or MERGE handle this? If the answer is yes, write the SQL. If the loop body is unavoidable but contains only DML, use FORALL. The loop-with-individual-DML pattern is the fastest path to a batch job that locks the table for thirty minutes instead of thirty seconds.
📊 Production Insight
A cursor loop processing 500,000 invoice rows with an individual UPDATE inside the body was causing a nightly batch job to run for 47 minutes and hold TM locks for most of that window — blocking every other session that needed to write to the same table. The rewrite was a BULK COLLECT into a collection followed by a single FORALL UPDATE. Runtime dropped to 38 seconds. Lock hold time dropped to under a second. The context-switch count went from 500,000 to effectively 1.
The diagnostic path was straightforward: V$SESSION showed the session actively executing for the full 47 minutes. V$SQL showed a high executions count on a simple single-row UPDATE. DBMS_XPLAN confirmed the plan was efficient — the loop itself was the problem, not the SQL. That is the pattern: when a simple SQL statement shows an execution count equal to your row count, you have an RBAR loop.
🎯 Key Takeaway
FOR LOOP and Cursor FOR LOOP are the safest defaults — implicit counter management, automatic cursor cleanup, and no path to an infinite loop.
FORALL is not a loop optimisation — it is a fundamentally different execution model. One SQL engine call instead of N. Use it whenever the loop body is a single DML statement.
Exception handling inside the loop body is not optional for batch processing — log the failing row and continue. One bad record should not abort thirty minutes of work.
Choosing the Right Loop Type
IfMust execute the body at least once, then check whether to continue
UseUse BASIC LOOP with EXIT WHEN — guarantees one execution before the condition is evaluated. Always add a safety ceiling.
IfThe dataset might be empty and the loop should not execute at all
UseUse WHILE LOOP — evaluates the condition before the first iteration, so an empty dataset means zero iterations
IfKnown iteration count or iterating over a query result
UseUse FOR LOOP or Cursor FOR LOOP — safest choice, counter and cursor lifecycle are fully automatic
IfLoop body contains a single INSERT, UPDATE, or DELETE
UseReplace with FORALL — eliminates per-row context switching, consistently 50–100x faster for bulk DML workloads
IfNeed to skip specific rows based on a condition without exiting
UseUse CONTINUE WHEN inside any loop type — skips the remainder of the current iteration and moves to the next
IfLoop processes rows where any single row might fail independently
UseWrap the loop body in BEGIN...EXCEPTION WHEN OTHERS, log the failing row to an error table, and continue — do not let one bad row abort the batch
🗂 PL/SQL Control Structures Comparison
When to use each structure in production Oracle development
StructureBest Use CaseKey KeywordEvaluation Timing
IF-THEN-ELSEConditional branching based on runtime values — use when different code paths must execute, not just different values be assignedELSIFTop-down at point of entry — first TRUE condition wins, rest skipped
BASIC LOOPWhen the body must execute at least once before the exit condition is checked — queue draining, retry logic, sequence generationEXIT WHENManual, inside the loop body — developer is responsible for ensuring the condition is reachable
FOR LOOPKnown iteration count or cursor traversal — safest choice because counter and bounds are implicit and infinite loops are impossibleIN / IN REVERSEAutomatic at range entry — bounds are fixed when the loop starts
WHILE LOOPCondition-driven iteration where the body may not need to execute at all — processing until a resource is exhausted or a flag is setWHILEBefore every iteration including the first — false on entry means zero executions
CASESelecting from mutually exclusive discrete options — cleaner than a long ELSIF chain for a single variable checked against known valuesWHENTop-down at point of entry — first matching WHEN clause wins

🎯 Key Takeaways

  • PL/SQL control structures exist to fill the gap SQL cannot — conditional branching on runtime state, iterative processing where each row's result affects the next, and error recovery that continues rather than aborts. Reach for them when set-based SQL genuinely cannot express the logic, not by default.
  • IF-THEN-ELSIF is evaluated top-down with short-circuit — first TRUE branch wins, everything else is skipped. NULL in any standard comparison returns NULL, not TRUE. IS NULL and IS NOT NULL are the only correct operators for null checks. No exceptions.
  • ELSE branches are not optional in batch processing — they are your audit point for unexpected states. A missing ELSE means unrecognized data silently passes through. Add an ELSE that writes to a persistent audit table in every batch IF block.
  • FOR LOOP and Cursor FOR LOOP are the safest defaults. They handle counter management, bounds checking, and cursor lifecycle implicitly. If you need to iterate over a query result, Cursor FOR LOOP eliminates ORA-01000 by design.
  • FORALL is not a loop variant — it is a fundamentally different execution model. One round trip to the SQL engine instead of N. If the loop body is a single DML statement, FORALL is the correct tool and the throughput improvement is not marginal.
  • Wrap loop bodies in BEGIN...EXCEPTION WHEN OTHERS in batch jobs. Log the failing row's key and error to a persistent table using an AUTONOMOUS_TRANSACTION. One bad record should not abort hours of successful processing.

⚠ Common Mistakes to Avoid

    RBAR processing instead of set-based SQL
    Symptom

    Batch job processes rows one-by-one inside a loop with an individual DML statement in the body. At 500,000 rows, this generates 500,000 context switches between the PL/SQL and SQL engines, produces excessive redo, holds row-level locks for the duration, and runs an order of magnitude slower than the equivalent set-based statement. The diagnostic signature is a V$SQL row showing executions equal to your row count.

    Fix

    If the entire loop body is a single INSERT, UPDATE, or DELETE, rewrite it as FORALL with a collection populated via BULK COLLECT. If the loop body contains conditional logic that makes FORALL impractical, reconsider whether a MERGE or a CASE expression inside an UPDATE could handle the branching at the SQL level. Profile with DBMS_PROFILER before and after to confirm the improvement — do not estimate.

    Failing to close cursors within loops
    Symptom

    ORA-01000: maximum open cursors exceeded after the procedure runs hundreds of times in the same session. Each loop iteration opens an explicit cursor and exits without closing it — the cursor slot remains consumed for the life of the session. The error only appears after the cursor table fills, which can be long after the offending code runs, making the root cause non-obvious.

    Fix

    Convert any cursor loop that does not need explicit fetch control to a Cursor FOR LOOP, which closes the cursor automatically even when an exception fires mid-loop. For explicit cursors that must remain, add CLOSE cursor_name in both the normal exit path and inside the EXCEPTION block. Query V$OPEN_CURSOR for the session to confirm cursors are being closed as expected.

    Ignoring error handling in loop bodies
    Symptom

    A single bad row — a type mismatch, a constraint violation, an unexpected NULL — raises an unhandled exception that propagates out of the loop and aborts the entire procedure. An overnight batch job that processed 480,000 records successfully fails on row 480,001 and rolls back to the last commit point. The job restarts from the beginning the next morning.

    Fix

    Wrap the loop body in a nested BEGIN...EXCEPTION WHEN OTHERS block. In the exception handler, insert the failing row's primary key, SQLCODE, and SQLERRM into a persistent error table using an AUTONOMOUS_TRANSACTION procedure so the log survives regardless of the outer transaction's fate. CONTINUE to the next iteration. Commit in batches — every 1,000 rows using MOD(v_counter, 1000) = 0 — so partial progress is preserved.

    NULL comparison using = operator
    Symptom

    IF v_status = NULL evaluates to NULL on every row where v_status is NULL. Since NULL is not TRUE, the IF block never executes. The code has no syntax error, raises no runtime exception, and produces no warning. The only evidence of the failure is in the data — records that should have been processed are untouched. In batch jobs, this can compound across thousands of rows before anyone notices.

    Fix

    Replace every = NULL with IS NULL and every <> NULL with IS NOT NULL, without exception. Search existing procedures with: SELECT line, text FROM ALL_SOURCE WHERE text LIKE '%= NULL%' AND type = 'PROCEDURE'. Add an ELSE branch to every IF block in batch code that writes unmatched states to an audit table with a timestamp and the actual variable value using NVL.

Interview Questions on This Topic

  • QWhat is the difference between a WHILE LOOP and a FOR LOOP in PL/SQL? When would you choose one over the other in a production environment?JuniorReveal
    The fundamental difference is when the exit condition is evaluated and who manages the counter. A WHILE LOOP checks its condition before every iteration including the first — if the condition is false on entry, the body never executes. A FOR LOOP operates over a fixed range or cursor: the bounds are determined when the loop starts, the counter is implicitly declared and incremented, and the loop exits automatically when the range is exhausted. In production, FOR LOOP is the safer default for cursor iteration and any scenario where the iteration count is known — you cannot accidentally create an infinite loop, and you do not have to remember to increment a counter or close a cursor. WHILE LOOP is the right choice when the exit condition depends on runtime state that cannot be determined before the loop starts: draining a queue until empty, polling an external resource, processing until a flag is set by a procedure called inside the loop. The risk with WHILE is that if the condition variable is never updated inside the loop body, you have an infinite loop. Always pair a WHILE with a safety ceiling.
  • QCan you explain why comparing a variable to NULL using the '=' operator inside an IF statement fails to return TRUE? What is the correct operator?Mid-levelReveal
    This is one of those things that bites everyone at least once and costs real money when it happens in a batch job. PL/SQL uses Three-Valued Logic inherited from SQL: values can be TRUE, FALSE, or NULL. The rule is that any operation involving NULL propagates NULL — including comparisons. So IF v_var = NULL does not evaluate to FALSE and does not evaluate to TRUE — it evaluates to NULL. The IF statement only executes its block when the condition is TRUE. NULL is not TRUE. The block is silently skipped. This is not a subtle compiler edge case — it is the defined behaviour of the language. The correct operator is IS NULL: IF v_var IS NULL evaluates to TRUE when the variable holds no value. Similarly, IS NOT NULL replaces <> NULL. The same rule applies in WHERE clauses in SQL — WHERE status = NULL returns zero rows; WHERE status IS NULL returns the nulls. In production, the failure mode is usually a batch job that runs to completion with zero errors but processes far fewer records than expected. The diagnostic is a count query: SELECT COUNT(*) FROM table WHERE column IS NULL — if that returns rows and your batch should have touched all of them, you have a NULL comparison bug somewhere in the conditional logic.
  • QHow does the CONTINUE statement differ from the EXIT statement inside a loop? Provide a use case for skipping an iteration.Mid-levelReveal
    EXIT terminates the loop entirely — control passes to the first statement after END LOOP, and no further iterations occur. CONTINUE skips the remainder of the current iteration and transfers control to the next evaluation of the loop condition, allowing subsequent iterations to proceed normally. The practical difference is significant in batch processing. If you are iterating over a result set of 10,000 invoices and encounter one with a NULL amount, EXIT would stop the entire batch at that record. CONTINUE skips that record and processes the remaining 9,999. The pattern inside a Cursor FOR LOOP looks like this: IF rec.amount IS NULL THEN log_error(rec.invoice_id, 'NULL amount skipped'); CONTINUE; END IF; -- processing logic here runs only for non-NULL amounts CONTINUE WHEN provides a shorthand: CONTINUE WHEN rec.amount IS NULL — it reads more clearly for simple conditions. For nested loops, CONTINUE targets the innermost loop by default. To skip to the next iteration of an outer loop, label the outer loop and reference the label: CONTINUE outer_loop WHEN condition.
  • QWhat is 'RBAR' and why is it considered an anti-pattern when using PL/SQL loops for large data updates?SeniorReveal
    RBAR stands for Row-By-Agonizing-Row — the pattern of processing one row at a time in a procedural loop when a set-based SQL statement could handle the entire dataset in a single operation. The term captures the frustration of watching a batch job crawl through hundreds of thousands of rows one at a time when it did not need to. The performance cost comes from context switching. PL/SQL and SQL are two separate engines in the Oracle architecture. Every time a PL/SQL loop fires a DML statement, control crosses from the PL/SQL engine to the SQL engine. For 500,000 rows, that is 500,000 context switches — each carrying a measurable overhead of stack frames, memory allocation, and engine coordination. The redo log grows with every individual DML call. Row-level locks accumulate across the duration of the loop. PGA memory usage climbs. The fix is FORALL. FORALL is not a loop — it sends a collection of DML operations to the SQL engine in a single call, processes them entirely in that engine with an optimized plan, and returns. The context switch happens once. Benchmark numbers vary by workload, but 50x to 100x throughput improvement for pure DML is well-documented and reproducible. The diagnostic: query V$SQL during the batch and look for a simple single-row UPDATE or INSERT with an executions count equal to your row count. That is RBAR.
  • QExplain the 'Short-Circuit' evaluation in PL/SQL IF statements. How does it improve performance in complex boolean conditions?SeniorReveal
    Short-circuit evaluation means the boolean expression stops being evaluated the moment the result is determined, regardless of how many more conditions remain. In an AND chain, if any condition evaluates to FALSE, the overall result must be FALSE — PL/SQL stops there and does not evaluate subsequent conditions. In an OR chain, if any condition evaluates to TRUE, the overall result is TRUE and the rest are skipped. This matters in production when conditions involve expensive operations — a function that runs a subquery, a calculation over a large dataset, or a network call. If you order conditions so that the cheapest, most likely to eliminate the row appears first, the expensive condition is only evaluated for rows that pass the guard. Example: IF record_count > 0 AND expensive_validation_function(record_id) THEN — if record_count is zero, the function is never called. Flip the order and you call the function on every empty record unnecessarily. The same principle applies to NULL safety: IF v_collection IS NOT NULL AND v_collection.COUNT > 0 THEN — if the collection is NULL, .COUNT is never evaluated, which prevents a potential ORA-06531 (reference to uninitialized collection). Short-circuit is not just a performance tool — it is a correctness tool when combined with NULL guards.
  • QHow would you use a Cursor FOR LOOP to simplify resource management (opening, fetching, and closing)?Mid-levelReveal
    A Cursor FOR LOOP collapses the OPEN, FETCH, and CLOSE lifecycle into the loop syntax itself. You declare neither the cursor variable nor an explicit OPEN statement. The loop implicitly opens the cursor when it starts, fetches each row into a strongly-typed record variable on each iteration, and closes the cursor when the loop exits — whether that exit is normal completion, an EXIT WHEN condition, or an unhandled exception propagating out. Syntax with an inline query: FOR rec IN (SELECT invoice_id, amount, status FROM invoices WHERE status IS NULL) LOOP -- rec.invoice_id, rec.amount, rec.status are available here process_invoice(rec.invoice_id, rec.amount); END LOOP; No OPEN, no FETCH, no CLOSE, no cursor variable declaration. If an exception fires inside the loop body and propagates out, Oracle closes the cursor. This is what eliminates ORA-01000 — there is no path where the cursor remains open after the loop exits. For named cursors used across multiple loops or when the query is complex enough to warrant a name, declare the cursor in the DECLARE section and reference it in the FOR clause: FOR rec IN cursor_name LOOP. The lifecycle management is identical. In production, Cursor FOR LOOP should be the default for any cursor-based iteration. The only reasons to reach for an explicit cursor are: you need bulk fetch with LIMIT for large datasets, you need to OPEN and CLOSE the cursor independently of iteration, or you need to pass the cursor between procedures.

Frequently Asked Questions

What is the difference between a CASE expression and an IF statement in PL/SQL?

The distinction is structural, not stylistic. A CASE expression returns a single value — it can appear on the right side of an assignment, inside a SQL SELECT list, or embedded in another expression. v_grade := CASE WHEN score >= 90 THEN 'A' WHEN score >= 75 THEN 'B' ELSE 'F' END is valid because CASE evaluates to a scalar value.

An IF statement controls execution flow — it executes blocks of procedural statements based on a condition but does not return a value and cannot appear inside an expression. Use CASE when you are selecting a value from a known set of mutually exclusive options, especially when you need that selection inside a SQL statement. Use IF when different code paths need to execute: different procedure calls, different error handling logic, different table writes — anything beyond assigning a single value.

Can I use CONTINUE in a nested loop to skip the inner loop and continue the outer loop?

Not directly — CONTINUE without a label always targets the innermost loop it is physically inside. To affect an outer loop from within an inner loop, label the outer loop and reference that label in the CONTINUE statement:

<<outer_loop>> FOR i IN 1..10 LOOP FOR j IN 1..10 LOOP CONTINUE outer_loop WHEN some_condition; -- This line is skipped when condition is true -- Control jumps to the next iteration of i, not j END LOOP; END LOOP;

Without the label, CONTINUE only skips to the next iteration of the j loop. The label must appear immediately before the LOOP keyword on the outer loop, and the same label name must appear after END LOOP for clarity, though that is optional syntax.

Is a FOR LOOP faster than a WHILE LOOP in PL/SQL?

For equivalent numeric range iteration, the difference is negligible in any real workload — the kind of micro-benchmark difference that disappears entirely against the noise of actual database I/O. The compiler handles both efficiently.

The reason to prefer FOR LOOP over WHILE is not speed — it is safety and readability. FOR LOOP cannot create an infinite loop because the bounds are fixed at entry and the counter is automatic. WHILE LOOP can create an infinite loop if the exit condition variable is never updated inside the body. FOR LOOP's intent is immediately clear from the range declaration. Choose based on which model fits the problem correctly, then let the optimizer handle the rest.

How do I handle exceptions inside a loop without aborting the entire batch?

Wrap the loop body in a nested BEGIN...EXCEPTION block. The exception handler catches the error for the current row, logs it to a persistent error table, and the loop continues to the next iteration:

FOR rec IN (SELECT * FROM invoices_to_process) LOOP BEGIN process_invoice(rec.invoice_id, rec.amount); EXCEPTION WHEN OTHERS THEN log_batch_error(rec.invoice_id, SQLCODE, SQLERRM); -- Loop continues automatically END; END LOOP;

The error logging procedure should use PRAGMA AUTONOMOUS_TRANSACTION so the log record commits independently of the outer transaction — otherwise a rollback would erase your error evidence. Commit the main transaction in batches — every 1,000 rows using MOD(v_row_count, 1000) = 0 — to bound undo/redo pressure and preserve partial progress if the job fails partway through.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousPL/SQL Variables, Constants and Data TypesNext →PL/SQL Cursors Explained
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged