Best Practices for Exception Handling in Oracle PL/SQL
- WHEN OTHERS has exactly two correct forms: log and re-raise (procedure level), or log and continue (batch level). Every other form silently hides production failures.
- Capture FORMAT_ERROR_STACK, FORMAT_ERROR_BACKTRACE, and SQLCODE into local variables as the first operation in every exception handler β any prior SQL overwrites the error context permanently.
- RAISE_APPLICATION_ERROR with keep_errors TRUE preserves the original error chain. FALSE (the default) discards it. Use TRUE inside exception handlers, FALSE in procedural code.
- 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
Need to find all WHEN OTHERS handlers in a package
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;Need to verify error logging procedure uses autonomous transactions
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;Need to check if FORMAT_ERROR_STACK is captured before SQL in exception handlers
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;Need to check recent error log entries and their completeness
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;Production Incident
Production Debug GuideDiagnosing why errors are hidden, misreported, or lost in PL/SQL systems
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.
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
| 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 |
π― Key Takeaways
- WHEN OTHERS has exactly two correct forms: log and re-raise (procedure level), or log and continue (batch level). Every other form silently hides production failures.
- Capture FORMAT_ERROR_STACK, FORMAT_ERROR_BACKTRACE, and SQLCODE into local variables as the first operation in every exception handler β any prior SQL overwrites the error context permanently.
- RAISE_APPLICATION_ERROR with keep_errors TRUE preserves the original error chain. FALSE (the default) discards it. Use TRUE inside exception handlers, FALSE in procedural code.
- Use PRAGMA AUTONOMOUS_TRANSACTION in every error logging procedure β without it, transaction rollbacks erase the error evidence.
- Catch at the level where the recovery decision can be made: inner functions propagate, procedures log and re-raise, batches log and continue, API boundaries return safe messages.
- Test every exception handler by forcing the error condition β verify the correct error code, a non-empty log entry, and batch continuation. Untested handlers fail in production.
β Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the correct way to use WHEN OTHERS in PL/SQL?JuniorReveal
- QWhy must FORMAT_ERROR_STACK be captured before any SQL in an exception handler?Mid-levelReveal
- QWhat does the keep_errors parameter in RAISE_APPLICATION_ERROR do and when should it be TRUE?Mid-levelReveal
- QHow do you design exception handling for a PL/SQL batch processing system?SeniorReveal
- QWhat is the difference between RAISE and RAISE_APPLICATION_ERROR in an exception handler?Mid-levelReveal
- QWhy should the error logging procedure use PRAGMA AUTONOMOUS_TRANSACTION?SeniorReveal
Frequently Asked Questions
What is the difference between RAISE and RAISE_APPLICATION_ERROR?
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.
Can I use SQLERRM instead of DBMS_UTILITY.FORMAT_ERROR_STACK?
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.
How do I handle errors in batch processing without halting the entire batch?
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.
Should every procedure have a WHEN OTHERS handler?
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.
How do I prevent exception handlers from hiding errors in production?
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.
What is PRAGMA AUTONOMOUS_TRANSACTION and why is it critical for error logging?
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.