Mid-level 5 min · March 09, 2026

PL/SQL IF — NULL Comparison Silently Skips 12K Records

12,000 invoices went unprocessed because PL/SQL's Three-Valued Logic treats NULL comparisons as NULL, not FALSE.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
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
Plain-English First

Think of PL/SQL control structures the same way you think about the decision logic baked into everyday systems — the thermostat that kicks in only when temperature drops below a threshold, the traffic light that cycles through states on a timer, the automated checkout that keeps scanning items until the belt is empty. IF-THEN is your traffic light: it looks at the current state and decides who moves. LOOP and WHILE are your shuttle routes — they keep running until a specific condition signals that the job is done. Without these constructs, your PL/SQL block is a straight line: it runs top to bottom, once, and exits. It cannot adapt, retry, or branch. These structures are what give database code actual intelligence rather than just mechanical execution.

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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
-- 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
Key Insight:
  • 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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
-- 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:
  • FORALL replaces INSERT/UPDATE/DELETE loops — one SQL engine call instead of N, routinely 50–100x faster
  • Cursor FOR LOOP handles open/fetch/close implicitly — closes the cursor even when an exception fires mid-loop, preventing ORA-01000
  • FOR LOOP over a range is the safest — counter is implicit, bounds are fixed, infinite loops are impossible
  • BASIC LOOP always needs a safety ceiling alongside the business exit condition — never trust the business condition alone
  • CONTINUE WHEN skips to the next iteration cleanly — use it instead of wrapping the entire loop body in an IF block
  • Commit in batches every N rows inside long-running loops — reduces undo/redo pressure and allows partial recovery on failure
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
● Production incidentPOST-MORTEMseverity: high

NULL Comparison in IF Block Silently Skipped Invoice Reconciliation for 12,000 Records

Symptom
Month-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.
Assumption
The 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 cause
PL/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.
Fix
Every = 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 comparisons
  • Three-Valued Logic means NULL comparisons silently evaluate to NULL, not TRUE — no error is raised
  • Add ELSE branches with audit logging to every IF block in batch jobs — silent skipping is worse than a visible failure
  • Post-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 issues5 entries
Symptom · 01
ORA-01000: maximum open cursors exceeded during batch processing
Fix
Find 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.
Symptom · 02
Batch job runs indefinitely with no errors in log
Fix
The 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.
Symptom · 03
IF block never executes despite the variable appearing to be set
Fix
The 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.
Symptom · 04
Batch processing 10x or more slower than expected
Fix
Confirm 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.
Symptom · 05
ORA-06502: PL/SQL numeric or value error in loop body
Fix
A 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.
★ PL/SQL Control Structure Quick Debug ReferenceImmediate actions for common PL/SQL loop and conditional issues in production
Infinite loop consuming all PGA memory
Immediate action
Identify 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 now
Add 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 action
Audit 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 now
Replace 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 action
Find 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 now
Convert 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 action
Confirm 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 now
Replace the loop with FORALL for DML operations or a single set-based UPDATE/INSERT. Confirm the rewrite with an explain plan before deploying.
PL/SQL Control Structures Comparison
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

1
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.
2
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.
3
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.
4
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.
5
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.
6
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

4 patterns
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a WHILE LOOP and a FOR LOOP in PL/SQL? Wh...
Q02SENIOR
Can you explain why comparing a variable to NULL using the '=' operator ...
Q03SENIOR
How does the CONTINUE statement differ from the EXIT statement inside a ...
Q04SENIOR
What is 'RBAR' and why is it considered an anti-pattern when using PL/SQ...
Q05SENIOR
Explain the 'Short-Circuit' evaluation in PL/SQL IF statements. How does...
Q06SENIOR
How would you use a Cursor FOR LOOP to simplify resource management (ope...
Q01 of 06JUNIOR

What 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?

ANSWER
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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between a CASE expression and an IF statement in PL/SQL?
02
Can I use CONTINUE in a nested loop to skip the inner loop and continue the outer loop?
03
Is a FOR LOOP faster than a WHILE LOOP in PL/SQL?
04
How do I handle exceptions inside a loop without aborting the entire batch?
🔥

That's PL/SQL. Mark it forged?

5 min read · try the examples if you haven't

Previous
PL/SQL Variables, Constants and Data Types
3 / 27 · PL/SQL
Next
PL/SQL Cursors Explained