PL/SQL Exception Handling — Silent 14k from WHEN OTHERS
14k transactions silently failed due to WHEN OTHERS THEN NULL.
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- WHEN OTHERS without RAISE or FORMAT_ERROR_STACK logging silently swallows every exception — the most common source of invisible production failures in Oracle
- Capture FORMAT_ERROR_STACK into a local variable as the FIRST statement in every exception handler — any prior SQL overwrites the error context
- RAISE_APPLICATION_ERROR with the third parameter TRUE preserves the original error chain when re-raising — FALSE (the default) discards it permanently
- Use PRAGMA AUTONOMOUS_TRANSACTION for error logging so log entries survive transaction rollbacks
- Catch at the level where the recovery decision can be made — not at the level where the error occurs
PL/SQL exception handling is Oracle's mechanism for managing runtime errors in procedural database code. The WHEN OTHERS clause is a catch-all handler that traps every unhandled exception, but it's also the most common source of silent data corruption in production Oracle systems.
A bare WHEN OTHERS THEN NULL; — or any handler that doesn't re-raise or log — swallows the error, leaving the transaction in an inconsistent state. This is the 'silent 14k bug': a reference to the Oracle error code ORA-01400 (cannot insert NULL) or similar data integrity errors that get masked, causing downstream reports to be off by thousands of dollars or records.
The core problem is that WHEN OTHERS without proper error context destroys the stack trace, making debugging nearly impossible.
Effective exception handling follows the 'first-statement rule': immediately capture error context (SQLCODE, SQLERRM, DBMS_UTILITY.FORMAT_ERROR_BACKTRACE) into a log table or variable before any other SQL or PL/SQL executes. This preserves the exact line and call stack where the error occurred.
For custom business logic errors, use RAISE_APPLICATION_ERROR with a negative error number (-20000 to -20999) and a meaningful message — this maintains the stack while providing application-specific error codes. The key architectural decision is exception propagation: catch exceptions at the boundary layer (typically the outermost procedure or API call) rather than in every nested block, unless you need to add context or perform cleanup.
Over-catching leads to lost errors; under-catching leads to unhandled exceptions crashing the session.
In practice, this means your exception handlers should be minimal and purposeful. A well-designed handler either: (1) logs the error with full context and re-raises, (2) translates a low-level Oracle error into a business-friendly RAISE_APPLICATION_ERROR, or (3) performs cleanup (rollback to savepoint, close cursors) and re-raises.
Never use WHEN OTHERS THEN NULL — it's the equivalent of catch (Exception e) {} in Java, but worse because Oracle's implicit transaction management means the failed DML is still pending. Testing every exception path is non-negotiable: use DBMS_OUTPUT or UTL_FILE to force errors at specific lines, and verify that your handlers fire correctly.
Tools like utPLSQL or custom error-injection frameworks can systematically test all WHEN OTHERS branches, which is where most production outages originate.
Exception handling in PL/SQL determines whether your system tells you when something breaks or whether it silently produces wrong answers and you find out three weeks later from an angry customer. The patterns in this guide — how you catch errors, what you log, when you re-raise, how you structure your error codes — are the difference between a system you can debug in five minutes and one that takes a week of forensics to diagnose.
Exception handling in PL/SQL is not about preventing crashes. It is about controlling what happens when errors occur and ensuring every failure is visible, diagnosable, and traceable.
Every exception handler is a design decision with three possible outcomes: propagate the error to the caller unchanged, transform it by adding context and re-raising, or suppress it by catching and continuing. Each outcome is correct in specific circumstances. The damage happens when the wrong outcome is chosen — particularly when errors are suppressed in contexts where they should propagate.
The three most destructive patterns in production PL/SQL systems are: WHEN OTHERS THEN NULL (which converts every failure into silent data corruption), exception handlers that execute SQL before capturing the error stack (which logs wrong or empty error context), and RAISE_APPLICATION_ERROR without the third parameter set to TRUE (which discards the original error from the stack permanently).
This guide covers every production-grade exception handling pattern with the failure mode each prevents and the production consequence of getting it wrong. Every code example is runnable on Oracle 19c, 21c, and 23ai. The patterns are validated across financial transaction processing, healthcare data systems, and logistics platforms processing millions of records daily.
This article is a companion to the ORA-06512 error stack reading guide and the ORA-01403 and ORA-01422 articles in this series. Cross-references are provided where the topics connect.
Why WHEN OTHERS Is a Silent 14k Bug
PL/SQL exception handling is the structured mechanism for capturing and responding to runtime errors in Oracle's procedural language. The core mechanic: exceptions propagate from inner blocks outward until caught by an EXCEPTION section or reach the caller. Unhandled exceptions roll back the current block's work and abort execution. The WHEN OTHERS clause catches every exception not explicitly named — including system-generated ones like NO_DATA_FOUND, TOO_MANY_ROWS, and ORA-06502 (value error).
In practice, exception handling follows block scope: each BEGIN-END block can have its own EXCEPTION section. Exceptions bubble up through nested blocks. A WHEN OTHERS in an outer block will catch anything missed by inner handlers. The key property: WHEN OTHERS does not reveal which exception occurred unless you explicitly capture SQLCODE and SQLERRM. Many teams lose 14,000+ rows of data because a WHEN OTHERS in a bulk-collect loop silently swallowed a DUP_VAL_ON_INDEX, causing the entire batch to roll back with no log.
Use exception handling to separate normal control flow from error recovery. Never use WHEN OTHERS at the top level without logging the error. In production systems, every WHEN OTHERS should re-raise or log SQLCODE and SQLERRM. The rule: catch specific exceptions first, then let WHEN OTHERS be the safety net that records the unknown error before re-raising it. This prevents silent data loss and makes debugging possible.
WHEN OTHERS: The Most Dangerous Exception Handler
WHEN OTHERS catches every exception that is not already caught by a specific handler in the same block. It is the broadest handler in PL/SQL. And when misused, it is the most destructive.
WHEN OTHERS has exactly two correct forms in production code. Form one: capture the full error context and re-raise the original exception with RAISE. This is the standard procedure-level handler — it ensures the error is logged with context and then continues propagating to the caller. Form two: capture the error context, log it, mark the failing record, and continue to the next iteration. This is the batch transaction-level handler — it ensures one failing record does not halt the entire batch.
Every other form of WHEN OTHERS is an error-hiding mechanism. WHEN OTHERS THEN NULL swallows the error entirely — no log entry, no alert, no indication to the caller that anything failed. WHEN OTHERS THEN RETURN returns control to the caller as if the operation succeeded — the caller has no way to distinguish success from silent failure. WHEN OTHERS that returns a default value gives the caller incorrect data from an operation that did not complete.
The correct handler depends on the block level. At the procedure level, log and re-raise. At the batch loop level, log, mark FAILED, and continue. At the API boundary, log the full stack server-side and return a user-safe error response to the client — never expose Oracle error codes, table names, or SQL text.
A practical audit step: grep your entire PL/SQL codebase for WHEN OTHERS THEN NULL and WHEN OTHERS THEN RETURN. Every match is a location where production errors are currently being swallowed. Schedule time to replace every one.
-- ============================================================ -- PATTERN 1: Log and re-raise (procedure level) -- Standard handler for most procedures -- ============================================================ CREATE OR REPLACE PROCEDURE charge_account( p_account_id IN NUMBER, p_amount IN NUMBER ) IS v_balance NUMBER; BEGIN SELECT balance INTO v_balance FROM accounts WHERE account_id = p_account_id; IF v_balance < p_amount THEN RAISE_APPLICATION_ERROR(-20001, 'Insufficient balance for account ' || p_account_id); END IF; UPDATE accounts SET balance = balance - p_amount WHERE account_id = p_account_id; EXCEPTION WHEN NO_DATA_FOUND THEN -- Specific handler: account does not exist error_pkg.log_and_raise_custom( p_module => 'charge_account', p_message => 'Account not found: ' || p_account_id, p_errno => -20002 ); WHEN OTHERS THEN -- Generic handler: capture full stack, log, re-raise error_pkg.log_and_reraise(p_module => 'charge_account'); END charge_account; / -- ============================================================ -- PATTERN 2: Log and continue (batch transaction level) -- Process each record independently — never halt the batch -- ============================================================ CREATE OR REPLACE PROCEDURE process_payment_batch( p_batch_date IN DATE, p_processed_count OUT NUMBER, p_error_count OUT NUMBER ) IS BEGIN p_processed_count := 0; p_error_count := 0; FOR rec IN ( SELECT transaction_id, account_id, amount FROM pending_transactions WHERE status = 'PENDING' AND batch_date = p_batch_date ORDER BY transaction_id ) LOOP BEGIN -- Process individual transaction charge_account(rec.account_id, rec.amount); UPDATE pending_transactions SET status = 'PROCESSED', processed_at = SYSTIMESTAMP WHERE transaction_id = rec.transaction_id; p_processed_count := p_processed_count + 1; IF MOD(p_processed_count, 1000) = 0 THEN COMMIT; END IF; EXCEPTION WHEN OTHERS THEN -- Capture error context FIRST — before any SQL DECLARE v_stack VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_STACK; v_bt VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_BACKTRACE; v_code NUMBER := SQLCODE; BEGIN -- Log the failure using autonomous transaction log_batch_error( p_batch_date => p_batch_date, p_txn_id => rec.transaction_id, p_error_code => v_code, p_error_stack => v_stack || CHR(10) || v_bt ); -- Mark the transaction as failed UPDATE pending_transactions SET status = 'FAILED', error_message = SUBSTR(v_stack, 1, 2000) WHERE transaction_id = rec.transaction_id; p_error_count := p_error_count + 1; -- Continue to next transaction — do NOT halt the batch END; END; END LOOP; COMMIT; -- Post-batch reconciliation: alert if error rate exceeds threshold IF p_error_count > 0 AND (p_processed_count + p_error_count) > 0 THEN DECLARE v_error_pct NUMBER := ROUND( 100 * p_error_count / (p_processed_count + p_error_count), 1 ); BEGIN IF v_error_pct > 5 THEN RAISE_APPLICATION_ERROR( -20020, 'Batch completed with ' || v_error_pct || '% error rate (' || p_error_count || ' of ' || (p_processed_count + p_error_count) || '). Check batch_error_log for details.' ); END IF; END; END IF; END process_payment_batch; / -- ============================================================ -- PATTERN 3: Log and return safe response (API boundary) -- Never expose raw Oracle errors to clients -- ============================================================ CREATE OR REPLACE FUNCTION api_get_balance( p_account_id IN NUMBER ) RETURN VARCHAR2 IS v_balance NUMBER; BEGIN SELECT balance INTO v_balance FROM accounts WHERE account_id = p_account_id; RETURN '{"status":"success","balance":' || v_balance || '}'; EXCEPTION WHEN NO_DATA_FOUND THEN -- 404 equivalent — account does not exist RETURN '{"status":"error","code":"NOT_FOUND","message":"Account not found"}'; WHEN OTHERS THEN -- Log full stack server-side — do not expose to client error_pkg.log_and_reraise(p_module => 'api_get_balance'); -- If the above re-raises, execution stops here -- The calling web server catches the exception and returns HTTP 500 RETURN '{"status":"error","code":"INTERNAL","message":"An unexpected error occurred"}'; END api_get_balance; /
The First-Statement Rule: Capture Error Context Before Any SQL
Oracle maintains exception context — the error code, message, and backtrace — in a session-level buffer. This buffer is overwritten when the next SQL statement executes. Any INSERT, UPDATE, DELETE, COMMIT, SELECT, or even a PL/SQL function call that internally executes SQL will replace the buffer contents.
This means every exception handler must capture FORMAT_ERROR_STACK, FORMAT_ERROR_BACKTRACE, and SQLCODE into local variables as the absolute first operation. No SQL can come first. Not cleanup SQL. Not status update SQL. Not the logging INSERT itself.
The failure mode is subtle: the error log INSERT succeeds, the log entry is created, but the error_stack column contains the result of the INSERT operation (which succeeded) rather than the original error (which triggered the handler). The log entry exists — it just contains the wrong error. During incident response, the team sees an error log entry with an empty or irrelevant stack and concludes the logging is broken when in fact the capture order is wrong.
The correct pattern uses a nested DECLARE block inside the WHEN OTHERS handler. The DECLARE block assigns all three error capture functions to local variables immediately — this is pure PL/SQL assignment, not SQL, so it does not overwrite the buffer. After assignment, any SQL can be executed safely because the original error context is preserved in the local variables.
For reusable error logging, extract the capture-and-log sequence into a separate procedure. The procedure must capture FORMAT_ERROR_STACK into its own local variables at the very top of its body — before the INSERT — because calling the procedure itself does not overwrite the buffer, but the INSERT inside it will.
-- ============================================================ -- WRONG: SQL before error capture — buffer is overwritten -- ============================================================ CREATE OR REPLACE PROCEDURE wrong_capture_order IS v_dummy VARCHAR2(1); BEGIN SELECT dummy INTO v_dummy FROM dual WHERE 1 = 0; -- Raises ORA-01403 EXCEPTION WHEN OTHERS THEN -- This INSERT overwrites the error buffer INSERT INTO app_error_log (module, error_stack, created_at) VALUES ('wrong_capture', DBMS_UTILITY.FORMAT_ERROR_STACK, SYSTIMESTAMP); -- ^^^ FORMAT_ERROR_STACK is called AFTER the INSERT -- The INSERT succeeded — the buffer now contains the INSERT result -- The log entry shows empty or wrong error information COMMIT; RAISE; END wrong_capture_order; / -- ============================================================ -- CORRECT: Capture into local variables FIRST, then SQL -- ============================================================ CREATE OR REPLACE PROCEDURE correct_capture_order IS v_dummy VARCHAR2(1); BEGIN SELECT dummy INTO v_dummy FROM dual WHERE 1 = 0; EXCEPTION WHEN OTHERS THEN -- Step 1: Capture ALL error context as first statements -- These are PL/SQL assignments, not SQL — they do not overwrite the buffer DECLARE v_error_stack VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_STACK; v_backtrace VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_BACKTRACE; v_error_code NUMBER := SQLCODE; v_call_stack VARCHAR2(4000) := DBMS_UTILITY.FORMAT_CALL_STACK; BEGIN -- Step 2: Now safe to execute SQL — error context is in local variables INSERT INTO app_error_log ( module, error_code, error_stack, backtrace, call_stack, created_at ) VALUES ( 'correct_capture', v_error_code, v_error_stack, v_backtrace, v_call_stack, SYSTIMESTAMP ); COMMIT; -- Step 3: Re-raise the original exception RAISE; END; END correct_capture_order; / -- ============================================================ -- CORRECT: Reusable autonomous transaction logging procedure -- Captures error context at the top, logs with independent commit -- ============================================================ CREATE OR REPLACE PROCEDURE log_error( p_module IN VARCHAR2, p_context_info IN VARCHAR2 DEFAULT NULL ) IS -- Capture error context IMMEDIATELY — before any SQL in this procedure v_error_stack VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_STACK; v_backtrace VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_BACKTRACE; v_error_code NUMBER := SQLCODE; v_call_stack VARCHAR2(4000) := DBMS_UTILITY.FORMAT_CALL_STACK; -- Autonomous transaction: commits independently of caller PRAGMA AUTONOMOUS_TRANSACTION; BEGIN INSERT INTO app_error_log ( module, error_code, error_stack, backtrace, call_stack, context_info, created_at ) VALUES ( p_module, v_error_code, v_error_stack, v_backtrace, v_call_stack, p_context_info, SYSTIMESTAMP ); COMMIT; -- Commits only this autonomous transaction -- Caller's transaction is unaffected — rollback does not erase this log entry END log_error; / -- ============================================================ -- Usage: clean exception handler with reusable logging -- ============================================================ CREATE OR REPLACE PROCEDURE process_refund( p_order_id IN NUMBER, p_amount IN NUMBER ) IS v_status VARCHAR2(20); BEGIN SELECT status INTO v_status FROM orders WHERE order_id = p_order_id; IF v_status != 'COMPLETED' THEN RAISE_APPLICATION_ERROR(-20010, 'Cannot refund order ' || p_order_id || ' — status is ' || v_status); END IF; UPDATE orders SET status = 'REFUNDED' WHERE order_id = p_order_id; UPDATE accounts SET balance = balance + p_amount WHERE order_id = p_order_id; EXCEPTION WHEN NO_DATA_FOUND THEN log_error('process_refund', 'order_id=' || p_order_id); RAISE_APPLICATION_ERROR(-20002, 'Order not found: ' || p_order_id, TRUE); WHEN OTHERS THEN log_error('process_refund', 'order_id=' || p_order_id || ', amount=' || p_amount); RAISE; -- Re-raise original exception END process_refund; /
- The buffer is session-level — any SQL in any block overwrites it
- INSERT, UPDATE, DELETE, COMMIT, and SELECT all overwrite the buffer on success or failure
- FORMAT_ERROR_STACK, FORMAT_ERROR_BACKTRACE, and SQLCODE all read from the same buffer
- PL/SQL variable assignment (:= DBMS_UTILITY.FORMAT_ERROR_STACK) reads the buffer without overwriting it — this is why capture into local variables works
- After capture, the buffer can be safely overwritten — the original error is preserved in the local variables
- PRAGMA AUTONOMOUS_TRANSACTION in the logging procedure ensures the log INSERT commits independently of the caller
RAISE_APPLICATION_ERROR: Custom Errors with Stack Preservation
RAISE_APPLICATION_ERROR raises a user-defined exception with a custom error code between -20000 and -20999 and a message of up to 2048 bytes. It is the primary mechanism for raising business-meaningful errors from PL/SQL to calling code.
The parameter that most developers miss is the third one — called keep_errors in Oracle documentation. When TRUE, the original error chain is preserved in the stack. The custom error is appended on top of whatever errors already exist. When FALSE — which is the default — the existing error stack is replaced entirely. Only the custom error and its own ORA-06512 entries remain.
The consequence of using FALSE when re-raising a caught exception: the root cause error (the ORA-XXXXX that triggered the exception handler) is permanently discarded from the stack. The error log shows only the custom message. Post-mortem debugging cannot determine why the error occurred — only that it was caught and re-raised.
The correct pattern: capture FORMAT_ERROR_STACK first (following the first-statement rule), then call RAISE_APPLICATION_ERROR with TRUE as the third parameter and include the original error text in the custom message. This preserves both the business context (the custom message) and the root cause (the original error chain).
For new errors that are not re-raises of caught exceptions — validation failures, business rule violations, authorization checks — use FALSE (the default) because there is no prior error to preserve.
A consistent error code scheme across the application enables programmatic handling by callers, log filtering by error type, and automated alerting based on error category.
-- ============================================================ -- WRONG: keep_errors defaults to FALSE — original error is lost -- ============================================================ DECLARE v_result NUMBER; BEGIN v_result := 1 / 0; -- Raises ORA-01476: divisor is equal to zero EXCEPTION WHEN OTHERS THEN -- FALSE is the default — discards ORA-01476 from the stack RAISE_APPLICATION_ERROR(-20000, 'Calculation failed'); -- Error stack now shows ONLY: ORA-20000: Calculation failed -- ORA-01476 is gone — post-mortem cannot determine root cause END; / -- ============================================================ -- CORRECT: keep_errors TRUE — original error chain preserved -- ============================================================ DECLARE v_result NUMBER; BEGIN v_result := 1 / 0; EXCEPTION WHEN OTHERS THEN DECLARE v_original VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_STACK; BEGIN -- TRUE preserves the original error in the stack RAISE_APPLICATION_ERROR( -20010, 'Tax calculation failed for order processing | ' || v_original, TRUE -- keep_errors: preserve original error chain ); END; -- Error stack now shows: -- ORA-20010: Tax calculation failed for order processing | ORA-01476: divisor is equal to zero -- ORA-06512: at line 12 -- ORA-01476: divisor is equal to zero <- Original error preserved END; / -- ============================================================ -- CORRECT: New error (not a re-raise) — FALSE is appropriate -- ============================================================ CREATE OR REPLACE PROCEDURE validate_order_amount( p_amount IN NUMBER ) IS BEGIN IF p_amount IS NULL THEN RAISE_APPLICATION_ERROR(-20001, 'Order amount cannot be null'); -- FALSE (default) is correct here — no prior error to preserve END IF; IF p_amount <= 0 THEN RAISE_APPLICATION_ERROR(-20001, 'Order amount must be positive: ' || p_amount); END IF; IF p_amount > 1000000 THEN RAISE_APPLICATION_ERROR(-20001, 'Order amount exceeds maximum: ' || p_amount); END IF; END validate_order_amount; / -- ============================================================ -- Centralized error code constants -- One package, all codes documented, enforced by code review -- ============================================================ CREATE OR REPLACE PACKAGE app_errors AS -- Validation errors: -20001 to -20009 c_validation CONSTANT NUMBER := -20001; -- Not found errors: -20010 to -20019 c_not_found CONSTANT NUMBER := -20010; -- Authorization errors: -20020 to -20029 c_auth_denied CONSTANT NUMBER := -20020; -- Business rule violations: -20030 to -20039 c_business_rule CONSTANT NUMBER := -20030; -- External service errors: -20040 to -20049 c_external_svc CONSTANT NUMBER := -20040; -- System/internal errors: -20100 to -20199 c_system_error CONSTANT NUMBER := -20100; -- Raise validation with field context PROCEDURE raise_validation(p_field IN VARCHAR2, p_message IN VARCHAR2); -- Raise not-found with entity context PROCEDURE raise_not_found(p_entity IN VARCHAR2, p_id IN VARCHAR2); -- Re-raise with business context — preserves original stack PROCEDURE reraise_with_context(p_module IN VARCHAR2, p_context IN VARCHAR2); -- Translate error code to user-safe message FUNCTION user_message(p_error_code IN NUMBER) RETURN VARCHAR2; END app_errors; / CREATE OR REPLACE PACKAGE BODY app_errors AS PROCEDURE raise_validation(p_field IN VARCHAR2, p_message IN VARCHAR2) IS BEGIN RAISE_APPLICATION_ERROR(c_validation, 'Validation [' || p_field || ']: ' || p_message); END; PROCEDURE raise_not_found(p_entity IN VARCHAR2, p_id IN VARCHAR2) IS BEGIN RAISE_APPLICATION_ERROR(c_not_found, p_entity || ' not found: ' || p_id); END; PROCEDURE reraise_with_context(p_module IN VARCHAR2, p_context IN VARCHAR2) IS v_original VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_STACK; BEGIN log_error(p_module, p_context); -- Autonomous transaction logging RAISE_APPLICATION_ERROR( c_system_error, p_context || ' | ' || SUBSTR(v_original, 1, 800), TRUE -- Preserve original error chain ); END; FUNCTION user_message(p_error_code IN NUMBER) RETURN VARCHAR2 IS BEGIN RETURN CASE WHEN p_error_code BETWEEN c_validation AND c_validation + 8 THEN 'Invalid input provided' WHEN p_error_code BETWEEN c_not_found AND c_not_found + 9 THEN 'Requested item not found' WHEN p_error_code BETWEEN c_auth_denied AND c_auth_denied + 9 THEN 'Access denied' WHEN p_error_code BETWEEN c_business_rule AND c_business_rule + 9 THEN 'Operation not allowed' WHEN p_error_code BETWEEN c_external_svc AND c_external_svc + 9 THEN 'External service unavailable — please retry' ELSE 'An unexpected error occurred' END; END; END app_errors; /
Exception Propagation: Catch at the Right Level
Not every exception should be caught. The decision depends on the abstraction level and whether the current block can make a meaningful recovery decision.
The propagation principle: catch at the level where the recovery decision can be made, not at the level where the error occurs. Inner functions that perform data lookups should let NO_DATA_FOUND propagate to the calling procedure, which has the business context to decide whether missing data is an error or a default case. The lookup function does not have that context.
The anti-pattern is catching too early. An inner block catches NO_DATA_FOUND and raises a generic ORA-20000 error. The outer block — which would have known exactly how to handle NO_DATA_FOUND — now sees only a generic custom error. It cannot distinguish a missing account from a missing configuration from a missing user preference. The specific exception type, which the outer block needed to make a recovery decision, was destroyed by the inner block's premature catch.
PL/SQL exception propagation follows a clear path: if the current block has no handler for the exception, it propagates to the enclosing block. If the enclosing block has no handler, it propagates further outward. If no handler exists at any level, the exception reaches the calling client.
The correct structure has four layers. Inner functions: no handlers — let specific exceptions propagate naturally. Procedure level: catch specific exceptions where you can add context, catch WHEN OTHERS to log and re-raise. Batch level: catch WHEN OTHERS to log, mark the record FAILED, and continue. API boundary: catch WHEN OTHERS to log the full stack server-side and return a safe response to the client.
-- ============================================================ -- Inner function: NO exception handler -- Let the caller decide what missing data means -- ============================================================ CREATE OR REPLACE FUNCTION get_balance( p_account_id IN NUMBER ) RETURN NUMBER IS v_balance NUMBER; BEGIN SELECT balance INTO v_balance FROM accounts WHERE account_id = p_account_id; RETURN v_balance; -- NO exception handler — NO_DATA_FOUND propagates to caller -- The caller has business context to decide the response END get_balance; / -- ============================================================ -- Procedure level: catch with context, re-raise -- Has business context to make recovery decisions -- ============================================================ CREATE OR REPLACE PROCEDURE process_payment( p_account_id IN NUMBER, p_amount IN NUMBER ) IS v_balance NUMBER; BEGIN v_balance := get_balance(p_account_id); -- If get_balance raises NO_DATA_FOUND, we catch it below -- with full business context IF v_balance < p_amount THEN app_errors.raise_validation('amount', 'Insufficient balance (' || v_balance || ') for payment of ' || p_amount); END IF; UPDATE accounts SET balance = balance - p_amount WHERE account_id = p_account_id; EXCEPTION WHEN NO_DATA_FOUND THEN -- We know the context: account_id was not found in accounts table log_error('process_payment', 'account_id=' || p_account_id); app_errors.raise_not_found('Account', TO_CHAR(p_account_id)); WHEN OTHERS THEN log_error('process_payment', 'account_id=' || p_account_id || ', amount=' || p_amount); RAISE; END process_payment; / -- ============================================================ -- Batch level: catch, log, mark failed, continue -- ============================================================ CREATE OR REPLACE PROCEDURE run_daily_payments IS v_ok NUMBER := 0; v_err NUMBER := 0; BEGIN FOR rec IN (SELECT * FROM payment_queue WHERE status = 'PENDING') LOOP BEGIN process_payment(rec.account_id, rec.amount); UPDATE payment_queue SET status = 'DONE' WHERE queue_id = rec.queue_id; v_ok := v_ok + 1; EXCEPTION WHEN OTHERS THEN DECLARE v_stack VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_STACK; BEGIN log_error('run_daily_payments', 'queue_id=' || rec.queue_id || ', account=' || rec.account_id); UPDATE payment_queue SET status = 'FAILED', error_msg = SUBSTR(v_stack, 1, 2000) WHERE queue_id = rec.queue_id; v_err := v_err + 1; END; END; END LOOP; COMMIT; DBMS_OUTPUT.PUT_LINE('Completed: ' || v_ok || ' ok, ' || v_err || ' failed'); END run_daily_payments; / -- ============================================================ -- API boundary: catch, log server-side, return safe response -- ============================================================ CREATE OR REPLACE FUNCTION api_process_payment( p_account_id IN NUMBER, p_amount IN NUMBER ) RETURN VARCHAR2 IS BEGIN process_payment(p_account_id, p_amount); RETURN '{"status":"success"}'; EXCEPTION WHEN OTHERS THEN DECLARE v_code NUMBER := SQLCODE; BEGIN log_error('api_process_payment', 'account_id=' || p_account_id || ', amount=' || p_amount); -- Map Oracle error code to user-safe message RETURN '{"status":"error","message":"' || app_errors.user_message(v_code) || '"}'; -- Never expose ORA-XXXXX codes, table names, or SQL text to clients END; END api_process_payment; /
- Inner functions: no handlers — let exceptions propagate. The function does not have business context to decide the response.
- Procedure level: catch specific exceptions where you have context. Add logging. Re-raise with business information.
- Batch level: catch WHEN OTHERS to log, mark FAILED, and continue. One failing record must not halt 500,000 others.
- API boundary: catch WHEN OTHERS to log the full stack server-side and return a safe, generic message to the client.
- Catching at any other layer — particularly catching too early in inner functions — destroys exception type information that outer layers need.
Testing Exception Handlers: Forcing Every Error Path
Exception handlers that have never been triggered in a test environment will fail when they fire in production. The failure mode is not a crash — it is a handler that appears to work but produces incorrect output: logs to the wrong table, returns the wrong default, raises a different exception than intended, or has a typo in the error message that omits the diagnostic key value.
The testing strategy has three components. First, force each specific exception by creating the conditions that trigger it: pass a non-existent ID for NO_DATA_FOUND, insert a duplicate for TOO_MANY_ROWS, call with an unauthorized role for authorization checks. Second, verify the handler's output: correct return value, correct error log entry with non-empty stack, correct error code in the raised exception. Third, verify the batch skip-and-continue behavior: insert a mix of valid and invalid records, run the batch, confirm all valid records processed and all invalid records marked FAILED with error context.
Automate all exception tests. Manual testing of error paths is unreliable because developers naturally focus on the happy path. An automated test that forces NO_DATA_FOUND on every deploy catches handler regressions before they reach production.
-- ============================================================ -- Test 1: NO_DATA_FOUND handler raises correct custom error -- ============================================================ CREATE OR REPLACE PROCEDURE test_not_found_raises IS BEGIN BEGIN process_payment(-99999, 100); -- Non-existent account -- If we reach this line, the handler did not raise RAISE_APPLICATION_ERROR(-20901, 'FAIL: expected exception but got success'); EXCEPTION WHEN OTHERS THEN IF SQLCODE = app_errors.c_not_found THEN DBMS_OUTPUT.PUT_LINE('PASS: NO_DATA_FOUND raises c_not_found (-20010)'); ELSIF SQLCODE = -20901 THEN DBMS_OUTPUT.PUT_LINE('FAIL: handler did not raise — returned silently'); RAISE; ELSE DBMS_OUTPUT.PUT_LINE('FAIL: expected -20010, got ' || SQLCODE); RAISE; END IF; END; END test_not_found_raises; / -- ============================================================ -- Test 2: WHEN OTHERS logs correctly and re-raises -- ============================================================ CREATE OR REPLACE PROCEDURE test_when_others_logs IS v_log_before NUMBER; v_log_after NUMBER; BEGIN SELECT COUNT(*) INTO v_log_before FROM app_error_log WHERE module = 'process_refund'; BEGIN -- Force an error: refund for non-existent order process_refund(-99999, 50); RAISE_APPLICATION_ERROR(-20902, 'FAIL: expected exception'); EXCEPTION WHEN OTHERS THEN IF SQLCODE NOT IN (-20002, -20902) THEN DBMS_OUTPUT.PUT_LINE('FAIL: unexpected error code ' || SQLCODE); RAISE; END IF; IF SQLCODE = -20902 THEN DBMS_OUTPUT.PUT_LINE('FAIL: handler did not raise'); RAISE; END IF; END; SELECT COUNT(*) INTO v_log_after FROM app_error_log WHERE module = 'process_refund'; IF v_log_after > v_log_before THEN -- Verify the log entry has a non-empty stack DECLARE v_stack_len NUMBER; BEGIN SELECT NVL(LENGTH(error_stack), 0) INTO v_stack_len FROM app_error_log WHERE module = 'process_refund' ORDER BY created_at DESC FETCH FIRST 1 ROWS ONLY; IF v_stack_len > 10 THEN DBMS_OUTPUT.PUT_LINE('PASS: WHEN OTHERS logged with non-empty stack'); ELSE DBMS_OUTPUT.PUT_LINE('FAIL: log entry has empty or short stack (' || v_stack_len || ' chars)'); END IF; END; ELSE DBMS_OUTPUT.PUT_LINE('FAIL: no log entry created'); END IF; END test_when_others_logs; / -- ============================================================ -- Test 3: Batch skip-and-continue -- ============================================================ CREATE OR REPLACE PROCEDURE test_batch_skip IS v_ok NUMBER; v_err NUMBER; BEGIN -- Setup: 5 queue items, item 3 has non-existent account DELETE FROM payment_queue WHERE queue_id BETWEEN 90001 AND 90005; INSERT INTO payment_queue (queue_id, account_id, amount, status) SELECT 90000 + level, CASE WHEN level = 3 THEN -1 ELSE level END, 100, 'PENDING' FROM dual CONNECT BY level <= 5; COMMIT; -- Ensure test accounts exist (except -1) FOR i IN 1..5 LOOP IF i != 3 THEN BEGIN INSERT INTO accounts (account_id, balance, status) VALUES (i, 10000, 'ACTIVE'); EXCEPTION WHEN DUP_VAL_ON_INDEX THEN NULL; END; END IF; END LOOP; COMMIT; -- Run batch run_daily_payments; -- Verify results SELECT COUNT(CASE WHEN status = 'DONE' THEN 1 END), COUNT(CASE WHEN status = 'FAILED' THEN 1 END) INTO v_ok, v_err FROM payment_queue WHERE queue_id BETWEEN 90001 AND 90005; IF v_ok = 4 AND v_err = 1 THEN DBMS_OUTPUT.PUT_LINE('PASS: batch processed 4, skipped 1 failure'); ELSE DBMS_OUTPUT.PUT_LINE('FAIL: expected 4 ok + 1 err, got ' || v_ok || ' ok + ' || v_err || ' err'); END IF; -- Cleanup DELETE FROM payment_queue WHERE queue_id BETWEEN 90001 AND 90005; COMMIT; EXCEPTION WHEN OTHERS THEN DELETE FROM payment_queue WHERE queue_id BETWEEN 90001 AND 90005; COMMIT; RAISE; END test_batch_skip; / -- ============================================================ -- Run all tests -- ============================================================ BEGIN DBMS_OUTPUT.PUT_LINE('=== Exception Handler Tests ==='); DBMS_OUTPUT.PUT_LINE(''); test_not_found_raises; test_when_others_logs; test_batch_skip; DBMS_OUTPUT.PUT_LINE(''); DBMS_OUTPUT.PUT_LINE('=== All tests complete ==='); END; /
- Force every NO_DATA_FOUND handler by passing a non-existent key — verify the correct custom error code is raised
- Force WHEN OTHERS by triggering an unexpected error — verify the handler logs a non-empty error stack and re-raises
- Force batch skip-and-continue by inserting a failing record — verify valid records process and the failure is logged with context
- Verify the error log entry has: non-empty error_stack, correct module name, correct error_code, and a created_at timestamp
- Automate all exception tests — manual testing of error paths is unreliable and will not catch regressions
Stop Catching NO_DATA_FOUND at the Wrong Scope
The most abused exception in PL/SQL is NO_DATA_FOUND. It's not always an error. A SELECT INTO returning zero rows is often legitimate logic. Yet I see developers wrapping every singleton SELECT in a WHEN NO_DATA_FOUND handler that sets a flag or returns null. That's lazy. It masks bugs. Here's the rule: catch NO_DATA_FOUND only at the block level where the absence of data is genuinely exceptional. If you expect zero rows, use an explicit cursor with a %NOTFOUND check instead. The difference is intent. Cursors communicate 'I know there might be no rows.' SELECT INTO with exception handling communicates 'I assumed there would be rows and something broke.' Don't blur the line. Your exception block should not be a crutch for bad SQL logic.
-- Bad: using exception to handle missing data DECLARE v_name employees.name%TYPE; BEGIN SELECT name INTO v_name FROM employees WHERE id = 999; EXCEPTION WHEN NO_DATA_FOUND THEN v_name := 'UNKNOWN'; END; -- Good: use cursor for expected empty result DECLARE CURSOR c_emp IS SELECT name FROM employees WHERE id = 999; v_name employees.name%TYPE; BEGIN OPEN c_emp; FETCH c_emp INTO v_name; IF c_emp%NOTFOUND THEN v_name := 'UNKNOWN'; END IF; CLOSE c_emp; END; /
Rethink Your Use of RAISE_APPLICATION_ERROR in Middleware
RAISE_APPLICATION_ERROR is perfect for propagating business rule violations to client apps. But I see teams calling it from every nested procedure and function. That's a mistake. Every call to RAISE_APPLICATION_ERROR aborts the current transaction and unwinds the call stack. If you call it from a helper function inside a larger transaction, you lose all prior changes. The alternative: use exception variables and let the top-level handler decide whether to roll back. Reserve RAISE_APPLICATION_ERROR for the outermost boundary — the procedure called by the application or trigger. Inside that, propagate user-defined exceptions with PRAGMA EXCEPTION_INIT or custom exception types. Keep the transaction control at the top. Your middle layers should never decide to roll back. They should signal. Let the orchestrator choose the recovery action.
-- Bad: helper raises error directly CREATE OR REPLACE FUNCTION validate_salary(p_sal NUMBER) RETURN BOOLEAN IS BEGIN IF p_sal < 0 THEN RAISE_APPLICATION_ERROR(-20001, 'Negative salary'); END IF; RETURN TRUE; END; -- Good: helper raises exception, outer block decides CREATE OR REPLACE FUNCTION validate_salary(p_sal NUMBER) RETURN BOOLEAN IS e_negative_salary EXCEPTION; BEGIN IF p_sal < 0 THEN RAISE e_negative_salary; END IF; RETURN TRUE; EXCEPTION WHEN e_negative_salary THEN RETURN FALSE; END; /
PRAGMA EXCEPTION_INIT: Your Private Catalog of Oracle Errors
Oracle raises hundreds of predefined exceptions. Most developers only handle NO_DATA_FOUND and TOO_MANY_ROWS. That's a missed opportunity. Use PRAGMA EXCEPTION_INIT to map specific Oracle error numbers — like -2291 (integrity constraint violation) or -2292 (child record found) — to named exceptions. This gives you granular control without relying on SQLCODE checking inside WHEN OTHERS. The trick: declare the exception first, then associate it with PRAGMA. You can only do this in the declaration section. Match the naming to the constraint name from DBA_CONSTRAINTS. This transforms your error handling from a catch-all mess into a legible contract. No more guessing what ORA-02291 means at 3 AM.
DECLARE e_fk_violation EXCEPTION; PRAGMA EXCEPTION_INIT(e_fk_violation, -2291); BEGIN INSERT INTO orders (customer_id) VALUES (99999); EXCEPTION WHEN e_fk_violation THEN DBMS_OUTPUT.PUT_LINE('Customer 99999 does not exist. Insert rolled back.'); -- Optionally re-raise with context RAISE_APPLICATION_ERROR(-20002, 'FK violation on orders.customer_id'); END; /
WHEN OTHERS THEN NULL Silently Swallowed 14,000 Failed Transactions Over a Holiday Weekend
- WHEN OTHERS THEN NULL converts every failure into silent success — the most dangerous single line of PL/SQL in production
- Dashboard metrics that measure batch completion instead of transaction success create a false confidence that masks systemic failures
- Post-batch reconciliation — comparing expected vs. actual counts — catches silent failures that no individual error handler can detect
- Grep your entire PL/SQL codebase for WHEN OTHERS THEN NULL today. Every instance is a time bomb. Remove all of them.
SELECT line, TRIM(text) AS code FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%WHEN OTHERS%' ORDER BY line;SELECT line, TRIM(text) AS code FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%THEN NULL%' ORDER BY line;SELECT line, TRIM(text) AS code FROM all_source WHERE name = 'ERROR_LOG_PKG' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%AUTONOMOUS%' ORDER BY line;SELECT line, TRIM(text) AS code FROM all_source WHERE name = 'ERROR_LOG_PKG' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%COMMIT%' ORDER BY line;SELECT line, TRIM(text) AS code FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND (UPPER(text) LIKE '%FORMAT_ERROR%' OR UPPER(text) LIKE '%INSERT INTO%error%' OR UPPER(text) LIKE '%WHEN OTHERS%') ORDER BY line;SELECT line, TRIM(text) AS code FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%SQLCODE%' ORDER BY line;SELECT log_id, module, error_code, CASE WHEN error_stack IS NULL OR LENGTH(error_stack) < 10 THEN 'EMPTY/SHORT' ELSE 'PRESENT' END AS stack_status, created_at FROM app_error_log WHERE created_at > SYSTIMESTAMP - INTERVAL '24' HOUR ORDER BY created_at DESC FETCH FIRST 50 ROWS ONLY;SELECT module, COUNT(*) AS empty_stack_count FROM app_error_log WHERE (error_stack IS NULL OR LENGTH(error_stack) < 10) AND created_at > SYSTIMESTAMP - INTERVAL '7' DAY GROUP BY module ORDER BY COUNT(*) DESC;| Pattern | Catches | Preserves Original Stack | Best Use | Risk if Misused |
|---|---|---|---|---|
| WHEN NO_DATA_FOUND | ORA-01403 only | Yes (propagates unchanged if re-raised with RAISE) | Specific handling for missing data — return default or raise business error | Returning a default when missing data is a configuration bug hides the problem |
| WHEN TOO_MANY_ROWS | ORA-01422 only | Yes (propagates unchanged if re-raised with RAISE) | Specific handling for duplicate data — always raise a diagnostic error | Returning a default when duplicates exist hides a data model bug |
| WHEN OTHERS + RAISE | All unhandled exceptions | Yes — RAISE propagates the original exception unchanged | Procedure-level default handler: log the error, then let it propagate | None — this is the safest generic handler |
| WHEN OTHERS + continue | All exceptions in a loop iteration | Yes — captured via FORMAT_ERROR_STACK into local variables before SQL | Batch transaction-level: log failure, mark record FAILED, process next | Must capture FORMAT_ERROR_STACK first or the log captures wrong context |
| RAISE_APPLICATION_ERROR with TRUE | N/A — raises a new exception | Yes — TRUE preserves the original error chain in the stack | Re-raising a caught exception with additional business context | None — this is the correct pattern for context-enriched re-raises |
| RAISE_APPLICATION_ERROR with FALSE | N/A — raises a new exception | No — FALSE replaces the entire stack | Raising a new error unrelated to a prior exception (validation, auth) | Using FALSE inside an exception handler permanently discards the root cause |
| WHEN OTHERS THEN NULL | All exceptions | No — exception is silently discarded | Never appropriate in production code | Converts every failure into silent data corruption |
| File | Command / Code | Purpose |
|---|---|---|
| when_others_patterns.sql | CREATE OR REPLACE PROCEDURE charge_account( | WHEN OTHERS |
| error_capture_order.sql | CREATE OR REPLACE PROCEDURE wrong_capture_order IS | The First-Statement Rule |
| raise_application_error_patterns.sql | DECLARE | RAISE_APPLICATION_ERROR |
| propagation_patterns.sql | CREATE OR REPLACE FUNCTION get_balance( | Exception Propagation |
| test_exception_handlers.sql | CREATE OR REPLACE PROCEDURE test_not_found_raises IS | Testing Exception Handlers |
| no_data_found_scope.sql | DECLARE | Stop Catching NO_DATA_FOUND at the Wrong Scope |
| raise_application_error_scope.sql | CREATE OR REPLACE FUNCTION validate_salary(p_sal NUMBER) RETURN BOOLEAN IS | Rethink Your Use of RAISE_APPLICATION_ERROR in Middleware |
| pragma_exception_init.sql | DECLARE | PRAGMA EXCEPTION_INIT |
Key takeaways
Common mistakes to avoid
6 patternsUsing WHEN OTHERS THEN NULL anywhere in production code
Executing SQL before capturing FORMAT_ERROR_STACK in the exception handler
Using RAISE_APPLICATION_ERROR without the third parameter TRUE when re-raising a caught exception
Using a single error code (-20000) for every custom error
Catching exceptions too early in inner utility functions
Error logging procedure does not use PRAGMA AUTONOMOUS_TRANSACTION
Interview Questions on This Topic
What is the correct way to use WHEN OTHERS in PL/SQL?
Why must FORMAT_ERROR_STACK be captured before any SQL in an exception handler?
What does the keep_errors parameter in RAISE_APPLICATION_ERROR do and when should it be TRUE?
How do you design exception handling for a PL/SQL batch processing system?
What is the difference between RAISE and RAISE_APPLICATION_ERROR in an exception handler?
Why should the error logging procedure use PRAGMA AUTONOMOUS_TRANSACTION?
Frequently Asked Questions
RAISE with no arguments re-raises the current exception unchanged — same error code, same message, same stack. Use it when you want to propagate the original error after logging. RAISE_APPLICATION_ERROR creates a new exception with a custom code (-20000 to -20999) and message. With keep_errors TRUE, both the custom error and the original error are visible in the stack. With FALSE (default), only the custom error remains. Use RAISE for unchanged propagation. Use RAISE_APPLICATION_ERROR with TRUE for context-enriched re-raises.
They are not interchangeable. SQLERRM returns the error message text for the current error (or for a specified error code) but does not include the ORA-06512 stack trace. DBMS_UTILITY.FORMAT_ERROR_STACK returns the full error chain including all ORA-XXXXX codes and messages. DBMS_UTILITY.FORMAT_ERROR_BACKTRACE returns the ORA-06512 call chain with line numbers. For error logging, capture both FORMAT_ERROR_STACK and FORMAT_ERROR_BACKTRACE. SQLERRM is useful when you need the message text for display — not for diagnostic logging.
Wrap each iteration of the batch loop in its own BEGIN/EXCEPTION/END block. In the WHEN OTHERS handler: capture FORMAT_ERROR_STACK first, log the error using an autonomous transaction procedure, update the failing record's status to FAILED with the error context, increment an error counter, and let the loop continue to the next iteration. After the loop completes, check the error count against a threshold — if more than 5% of records failed, raise an alert. This pattern processes all valid records, preserves detailed error information for failures, and detects systematic issues through the threshold check.
No. Inner utility functions and data access functions should generally have no WHEN OTHERS handler — they should let exceptions propagate to the calling layer that has business context to make recovery decisions. WHEN OTHERS handlers belong at three levels: procedure level (log and re-raise), batch level (log and continue), and API boundary (log and return safe message). Adding WHEN OTHERS to every function creates context-destroying checkpoints that prevent callers from seeing specific exception types.
Four practices. First: remove every WHEN OTHERS THEN NULL from the codebase — grep for it and eliminate all instances. Second: enforce the first-statement rule — FORMAT_ERROR_STACK capture before any SQL in every handler. Third: use RAISE_APPLICATION_ERROR with keep_errors TRUE when re-raising caught exceptions. Fourth: use PRAGMA AUTONOMOUS_TRANSACTION in error logging procedures so log entries survive rollbacks. Enforce these practices through code review and add automated tests that force exceptions and verify the handler behavior.
PRAGMA AUTONOMOUS_TRANSACTION declares that a procedure runs in its own independent transaction. Its COMMIT and ROLLBACK affect only its own operations, not the caller's transaction. For error logging, this means the INSERT into the error log table is committed independently. If the caller subsequently issues a ROLLBACK (which is common after a failed operation), the error log entry survives. Without PRAGMA AUTONOMOUS_TRANSACTION, the log INSERT is part of the caller's transaction — a rollback erases the error evidence, and the failure appears to have never occurred.
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
That's PL/SQL. Mark it forged?
8 min read · try the examples if you haven't