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
✦ Definition~90s read
What is PL/SQL Control Structures?
PL/SQL control structures are the procedural backbone of Oracle Database, letting you write conditional branches and loops directly inside SQL execution contexts. Unlike pure SQL, which operates on sets, PL/SQL gives you row-by-row decision-making — IF-THEN-ELSIF for branching, and three loop types (basic, FOR, WHILE) for iteration.
★
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.
The gotcha is that NULL comparisons in IF conditions don't throw errors; they silently evaluate to UNKNOWN, causing entire branches to be skipped. This is why a WHERE clause filtering on column = NULL returns zero rows, and the same logic in a PL/SQL IF block can bypass 12K records without a single warning.
You use PL/SQL control structures when you need procedural logic — validation, batch processing, or complex transformations — that can't be expressed in a single SQL statement. Don't use them for set-based operations that SQL handles faster; Oracle's optimizer can't parallelize or index your IF-THEN-ELSIF chains.
The architecture matters: your code runs inside the PL/SQL engine, which sits atop the SQL engine. Every control structure invocation triggers a context switch between the two, and the undocumented five-step handshake — parse, bind, execute, fetch, close — happens per iteration in loops.
That's why a loop with 12K iterations and a silent NULL comparison doesn't just skip records; it burns through context switches while producing wrong results.
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.
How PL/SQL Control Structures Decide Your Data Fate
PL/SQL control structures are the procedural logic that governs statement execution order — IF-THEN-ELSE, CASE, loops, and GOTO. Unlike SQL's set-based operations, these structures evaluate conditions row-by-row or block-by-block, making them essential for business rules that can't be expressed in a single query. The core mechanic: each condition is a Boolean expression that returns TRUE, FALSE, or NULL — and NULL is not FALSE. This three-valued logic is the root of most silent failures.
In practice, an IF statement checks a condition and executes the THEN branch only if it evaluates to TRUE. If the condition is FALSE or NULL, control falls to ELSIF or ELSE. The critical nuance: comparing a variable to NULL with '=' or '<>' always yields NULL, never TRUE. So 'IF salary <> 5000' will skip rows where salary IS NULL, even though you intended to include them. This is not a bug — it's the SQL standard — but it catches teams constantly.
Use PL/SQL control structures when you need conditional branching, looping, or exception handling that pure SQL cannot provide — typically in stored procedures, triggers, or batch validation. They matter because they enforce data integrity at the database level, but misuse (especially NULL comparisons) silently corrupts results. In production, a single IF statement with an unhandled NULL can exclude 12K records from a payroll run, and no error is raised.
⚠ NULL Is Not a Value — It's the Absence of One
Comparing anything to NULL with =, <>, <, or > always yields NULL, not TRUE or FALSE. Use IS NULL or IS NOT NULL explicitly.
📊 Production Insight
A batch payroll procedure used IF salary <> 5000 to filter non-standard salaries — but salary was NULL for contractors, so 12K contractor records were silently excluded from the run.
The symptom: no error, no warning, just a lower total payout that went unnoticed until the next audit.
Rule of thumb: always treat NULL comparisons as potential row eliminators — add explicit IS NULL checks or use NVL/COALESCE to provide a default.
🎯 Key Takeaway
PL/SQL control structures use three-valued logic (TRUE, FALSE, NULL) — NULL in a condition is not FALSE, it's unknown.
An IF condition that evaluates to NULL skips the THEN branch without error — this is the #1 source of silent data loss.
Always guard comparisons with IS NULL or COALESCE when the column can contain NULLs.
thecodeforge.io
Plsql Control Structures
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 trailDECLARE
v_counter NUMBER := 1;
v_threshold CONSTANTNUMBER := 5;
v_status VARCHAR2(20);
v_raw_input VARCHAR2(20) := NULL; -- Simulating a NULL-status recordBEGIN-- Demonstrate correct NULL handling — IS NULL, never = NULLIF v_raw_input ISNULLTHEN
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'));
ENDIF;
-- IF-THEN-ELSIF driving a WHILE LOOP — realistic batch patternWHILE v_counter <= v_threshold LOOPIF v_counter < 3THEN
v_status := 'INITIALIZING';
ELSIF v_counter BETWEEN3AND4THEN
v_status := 'PROCESSING';
ELSE
v_status := 'FINALIZING';
ENDIF;
DBMS_OUTPUT.PUT_LINE(
'Iteration ' || v_counter || ' | Status: ' || v_status
);
v_counter := v_counter + 1;
ENDLOOP;
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.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 resilienceDECLARE-- Type declarations for BULK COLLECT and FORALL patternTYPE t_invoice_ids ISTABLEOF invoices.invoice_id%TYPEINDEXBY PLS_INTEGER;
TYPE t_amounts ISTABLEOF invoices.amount%TYPEINDEXBY 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)PROCEDURElog_error(p_id INNUMBER, p_msg INVARCHAR2) ISPRAGMA 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 IN1..v_max LOOP
DBMS_OUTPUT.PUT_LINE('Iteration: ' || i);
ENDLOOP;
-- ----------------------------------------------------------------- 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;
EXITWHEN v_idx > v_max; -- Business exit condition-- EXIT WHEN v_idx > 10000; -- Safety ceiling (always add this)ENDLOOP;
-- ----------------------------------------------------------------- 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
CONNECTBY level <= v_max
) LOOPBEGINIF rec.amount ISNULLTHENlog_error(rec.invoice_id, 'NULL amount — skipped');
CONTINUE; -- Skip this row, process the restENDIF;
DBMS_OUTPUT.PUT_LINE(
'Invoice ' || rec.invoice_id ||
' | Amount: ' || rec.amount
);
EXCEPTIONWHENOTHERSTHENlog_error(rec.invoice_id, SQLERRM);
-- Loop continues to next row automaticallyEND;
ENDLOOP;
-- ----------------------------------------------------------------- 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
thecodeforge.io
Plsql Control Structures
PL/SQL Architecture: Where Your Code Actually Runs
Every IF, LOOP, and EXCEPTION you write gets handed off between two distinct engines. Understanding this handshake is what separates devs who debug in minutes from those who stare at logs for hours.
The PL/SQL engine handles your procedural logic—conditions, loops, variable assignments. The SQL engine takes your DML and passes it down to the database server. They're not the same process. They don't share memory. They communicate across a boundary that can kill performance if you're careless.
When you write a FOR loop with a SQL statement inside, the PL/SQL engine doesn't execute that query. It ships each iteration's SQL to the SQL engine separately. That's why a single cursor-FOR loop that fetches 10,000 rows will make 10,000 round trips unless you use bulk collect. Know your architecture before you cry about slow stored procedures.
ArchitectureHandshake.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — database tutorial
DECLARECURSOR c_orders ISSELECT order_id, total_amount
FROM orders
WHERE status = 'PENDING';
v_count NUMBER := 0;
BEGIN-- Each iteration ships a FETCH to SQL engineFOR rec IN c_orders LOOP
v_count := v_count + 1;
-- PL/SQL engine does the counting-- SQL engine does the FETCHENDLOOP;
DBMS_OUTPUT.PUT_LINE('Processed: ' || v_count);
END;
/
Output
Processed: 482
⚠ Production Trap:
Putting a SELECT inside a loop creates context switching between PL/SQL and SQL engines on every iteration. Always use BULK COLLECT or FORALL to batch work—your DBA will thank you.
🎯 Key Takeaway
PL/SQL procedural logic runs in one engine; SQL runs in another. Every loop iteration that contains SQL adds context-switching overhead.
Execution Flow: The Five-Step Handshake Nobody Documents
Here's what actually happens when you hit F5 on that block. Step one: your PL/SQL block gets parsed and sent to the server. The PL/SQL engine takes control first—it evaluates your procedural statements, resolves variable references, and builds a statement queue.
Step two: when it hits a SQL statement, the PL/SQL engine passes that chunk to the SQL engine. Not your whole block. Just the SQL. The SQL engine parses, optimizes, and executes the query against the database server.
Step three: the database server does the actual table scan, join, or index lookup. It returns rows to the SQL engine. Step four: the SQL engine passes results back to the PL/SQL engine. Step five: your procedural code continues with those results in memory.
Five handoffs. Every single SQL statement. That's why context is king—you want to minimize how many times you cross that boundary.
Use AUTONOMOUS_TRANSACTION sparingly—it creates a separate session context and bypasses the normal execution flow entirely. Great for logging, disaster for data integrity if you don't understand the isolation implications.
🎯 Key Takeaway
Every DML in a PL/SQL block triggers a five-step handshake. Minimize round trips between PL/SQL and SQL engines by batching operations.
● 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
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
Structure
Best Use Case
Key Keyword
Evaluation Timing
IF-THEN-ELSE
Conditional branching based on runtime values — use when different code paths must execute, not just different values be assigned
ELSIF
Top-down at point of entry — first TRUE condition wins, rest skipped
BASIC LOOP
When the body must execute at least once before the exit condition is checked — queue draining, retry logic, sequence generation
EXIT WHEN
Manual, inside the loop body — developer is responsible for ensuring the condition is reachable
FOR LOOP
Known iteration count or cursor traversal — safest choice because counter and bounds are implicit and infinite loops are impossible
IN / IN REVERSE
Automatic at range entry — bounds are fixed when the loop starts
WHILE LOOP
Condition-driven iteration where the body may not need to execute at all — processing until a resource is exhausted or a flag is set
WHILE
Before every iteration including the first — false on entry means zero executions
CASE
Selecting from mutually exclusive discrete options — cleaner than a long ELSIF chain for a single variable checked against known values
WHEN
Top-down at point of entry — first matching WHEN clause wins
⚙ Quick Reference
4 commands from this guide
File
Command / Code
Purpose
iothecodeforgeplsqlControlFlowExample.sql
DECLARE
Conditional Logic
iothecodeforgeplsqlLoopPatterns.sql
DECLARE
Iterative Logic
ArchitectureHandshake.sql
DECLARE
PL/SQL Architecture
ExecutionTrace.sql
DECLARE
Execution Flow
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.
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.
Q02 of 06SENIOR
Can you explain why comparing a variable to NULL using the '=' operator inside an IF statement fails to return TRUE? What is the correct operator?
ANSWER
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.
Q03 of 06SENIOR
How does the CONTINUE statement differ from the EXIT statement inside a loop? Provide a use case for skipping an iteration.
ANSWER
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.
Q04 of 06SENIOR
What is 'RBAR' and why is it considered an anti-pattern when using PL/SQL loops for large data updates?
ANSWER
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.
Q05 of 06SENIOR
Explain the 'Short-Circuit' evaluation in PL/SQL IF statements. How does it improve performance in complex boolean conditions?
ANSWER
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.
Q06 of 06SENIOR
How would you use a Cursor FOR LOOP to simplify resource management (opening, fetching, and closing)?
ANSWER
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.
01
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?
JUNIOR
02
Can you explain why comparing a variable to NULL using the '=' operator inside an IF statement fails to return TRUE? What is the correct operator?
SENIOR
03
How does the CONTINUE statement differ from the EXIT statement inside a loop? Provide a use case for skipping an iteration.
SENIOR
04
What is 'RBAR' and why is it considered an anti-pattern when using PL/SQL loops for large data updates?
SENIOR
05
Explain the 'Short-Circuit' evaluation in PL/SQL IF statements. How does it improve performance in complex boolean conditions?
SENIOR
06
How would you use a Cursor FOR LOOP to simplify resource management (opening, fetching, and closing)?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
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.