Skip to content
Homeβ€Ί Databaseβ€Ί Best Practices for Exception Handling in Oracle PL/SQL

Best Practices for Exception Handling in Oracle PL/SQL

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: PL/SQL β†’ Topic 11 of 27
Production-grade exception handling patterns for Oracle PL/SQL β€” WHEN OTHERS, RAISE_APPLICATION_ERROR with stack preservation, autonomous transaction logging, error capture ordering, batch skip-and-continue, centralized error packages, and testing strategies.
πŸ”₯ Advanced β€” solid Database foundation required
In this tutorial, you'll learn
Production-grade exception handling patterns for Oracle PL/SQL β€” WHEN OTHERS, RAISE_APPLICATION_ERROR with stack preservation, autonomous transaction logging, error capture ordering, batch skip-and-continue, centralized error packages, and testing strategies.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • 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
🚨 START HERE
PL/SQL Exception Handling Quick Debug Cheat Sheet
Fast diagnostics for exception handling problems. Run against the database where the error occurred.
🟑Need to find all WHEN OTHERS handlers in a package
Immediate ActionSearch ALL_SOURCE for WHEN OTHERS and classify each handler
Commands
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;
Fix NowEvery WHEN OTHERS must either call RAISE or capture FORMAT_ERROR_STACK and log before continuing. Every THEN NULL match is a silent failure point. Remove all of them.
🟑Need to verify error logging procedure uses autonomous transactions
Immediate ActionCheck the logging procedure for PRAGMA AUTONOMOUS_TRANSACTION
Commands
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;
Fix NowIf no AUTONOMOUS_TRANSACTION is found, error log entries are lost on rollback. Add PRAGMA AUTONOMOUS_TRANSACTION to the logging procedure and add COMMIT inside it.
🟑Need to check if FORMAT_ERROR_STACK is captured before SQL in exception handlers
Immediate ActionLook for INSERT or UPDATE statements that appear before FORMAT_ERROR_STACK in exception blocks
Commands
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;
Fix NowWithin each exception block, FORMAT_ERROR_STACK assignments must appear before any INSERT, UPDATE, or COMMIT. If they appear after, the captured stack is wrong or empty.
🟑Need to check recent error log entries and their completeness
Immediate ActionQuery the error log and check for empty stacks
Commands
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;
Fix NowModules with empty stack counts have handlers that execute SQL before FORMAT_ERROR_STACK. Fix the capture order in those modules.
Production IncidentWHEN OTHERS THEN NULL Silently Swallowed 14,000 Failed Transactions Over a Holiday WeekendA payment processing system silently discarded 14,000 failed transactions because every procedure wrapped its logic in WHEN OTHERS THEN NULL. No errors were logged. No alerts fired. The batch reported 100% completion. Customers discovered the failures Monday morning.
SymptomMonday morning: 2,300 customer complaints about missing payment confirmations. The payment processing dashboard showed 100% success for the weekend batch. No errors appeared in the application error log. The database showed 14,000 fewer completed transactions than expected for a typical weekend volume. The batch scheduler showed the job completed on time with exit code 0.
AssumptionThe team assumed a network partition had prevented the batch from running. They spent four hours checking network logs, message queue health, and scheduler configuration before a junior engineer checked the raw database transaction counts and discovered the batch had run and completed β€” it had just processed zero payments successfully while reporting success.
Root causeEvery procedure in the payment processing package had a WHEN OTHERS THEN NULL exception handler at its outermost block. This pattern had been copied from a template created years earlier by a developer who intended it as a temporary placeholder during development. It was never replaced with proper error handling. During the holiday weekend, a third-party payment gateway experienced intermittent HTTP 503 errors. For each of the 14,000 affected transactions, the gateway call raised an exception, WHEN OTHERS caught it, NULL executed, and the procedure returned control to the batch loop. The batch loop interpreted the return as success. No error was logged. No status was updated. The transaction remained in PENDING status indefinitely. The dashboard showed 100% completion because it counted completed batch runs, not successful transactions. The metric was measuring the wrong thing.
FixRemoved every WHEN OTHERS THEN NULL from the codebase β€” 23 instances across 8 packages. Replaced each with a pattern appropriate to its block level: WHEN OTHERS that captures FORMAT_ERROR_STACK and calls RAISE at the procedure level; WHEN OTHERS that logs the error, marks the transaction FAILED, and continues at the batch loop level. Added a post-batch reconciliation procedure that compares expected transaction count against actual completed count and raises an alert if the counts diverge by more than 1%. Changed the dashboard to report successful transactions, not successful batch runs.
Key Lesson
WHEN OTHERS THEN NULL converts every failure into silent success β€” the most dangerous single line of PL/SQL in productionDashboard metrics that measure batch completion instead of transaction success create a false confidence that masks systemic failuresPost-batch reconciliation β€” comparing expected vs. actual counts β€” catches silent failures that no individual error handler can detectGrep your entire PL/SQL codebase for WHEN OTHERS THEN NULL today. Every instance is a time bomb. Remove all of them.
Production Debug GuideDiagnosing why errors are hidden, misreported, or lost in PL/SQL systems
Error occurs in the application but no entry appears in the error log table→A WHEN OTHERS handler is swallowing the exception without logging. Search for WHEN OTHERS in the call chain: SELECT line, text FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%WHEN OTHERS%'. Verify every match either calls RAISE or logs FORMAT_ERROR_STACK before continuing.
Error log entry exists but shows an empty error stack or wrong error code→SQL was executed before FORMAT_ERROR_STACK was captured. Oracle overwrites the error buffer on the next SQL statement. Fix: capture FORMAT_ERROR_STACK, FORMAT_ERROR_BACKTRACE, and SQLCODE into local variables as the FIRST statements in the handler — before any INSERT, UPDATE, COMMIT, or SELECT.
Error log shows a custom error code (ORA-20xxx) but no original Oracle error→RAISE_APPLICATION_ERROR was called with the third parameter (keep_errors) set to FALSE or omitted (FALSE is the default). The original error chain was discarded. Fix: use RAISE_APPLICATION_ERROR(errno, message, TRUE) when re-raising a caught exception to preserve the original stack.
Every error in the log shows the same error code (-20000) regardless of failure type→The application is using a single error code for all custom errors. Implement a consistent error code scheme with ranges by module and error type. Use a centralized error package that defines all error codes as named constants.
Error log entry disappears after a transaction rollback→The error logging procedure is not using PRAGMA AUTONOMOUS_TRANSACTION. The INSERT into the error log table is part of the same transaction that failed. When the caller rolls back, the error log INSERT is also rolled back. Fix: add PRAGMA AUTONOMOUS_TRANSACTION to the logging procedure so its COMMIT is independent of the caller's transaction.
Raw Oracle error messages are reaching the client application or end user→No API-boundary exception handler exists, or it is re-raising the raw exception. Add a top-level WHEN OTHERS handler at the API boundary that logs the full Oracle stack server-side and returns a user-safe error message. Never expose ORA-XXXXX codes, table names, or SQL text to external clients.
Batch job reports 100% success but transaction counts do not match→The batch loop has a WHEN OTHERS handler that catches and swallows exceptions without updating the transaction status. The batch loop sees the return from the handler as success. Fix: every batch-level WHEN OTHERS must mark the failing record as FAILED and increment an error counter. Add post-batch reconciliation.

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.

when_others_patterns.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
-- ============================================================
-- 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;
/
⚠ WHEN OTHERS Anti-Patterns That Hide Production Failures
πŸ“Š Production Insight
In a codebase audit across three production Oracle systems, we found an average of 31 WHEN OTHERS THEN NULL instances per schema. Each one was a location where errors had been silently discarded β€” some for years. The audit and replacement took two days. The alternative β€” continuing to silently corrupt data β€” was not an option.
🎯 Key Takeaway
WHEN OTHERS has two correct forms: log and re-raise at the procedure level, log and continue at the batch level. Every other form silently hides production failures. Grep your codebase for WHEN OTHERS THEN NULL and remove every instance.

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.

error_capture_order.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
-- ============================================================
-- 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;
/
Mental Model
The Error Buffer Model
Oracle's error buffer is a single slot. The next SQL operation fills it with its own result, permanently erasing the original error.
  • 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
πŸ“Š Production Insight
Error logs that show empty stacks or wrong errors are almost always caused by SQL executing before FORMAT_ERROR_STACK capture. The logging INSERT succeeded β€” the log entry exists β€” but it contains the INSERT's result context, not the original error. During incident response, the team sees log entries and concludes the errors were minor or non-diagnostic. The real error was never captured.
🎯 Key Takeaway
Capture FORMAT_ERROR_STACK, FORMAT_ERROR_BACKTRACE, and SQLCODE into local variables as the absolute first operation in every exception handler. Use PRAGMA AUTONOMOUS_TRANSACTION in the logging procedure so log entries survive transaction rollbacks. The first-statement rule is not a guideline β€” it is the only way to capture correct error context.

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.

raise_application_error_patterns.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
-- ============================================================
-- 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;
/
πŸ”₯keep_errors Parameter: When to Use TRUE vs FALSE
TRUE: use when re-raising a caught exception with additional business context. The caller sees both your custom message and the original Oracle error. Post-mortem analysis can trace the full chain. FALSE (default): use when raising a new error that is not related to a previously caught exception β€” validation failures, business rule checks, authorization denials. There is no prior error to preserve. The rule: if your RAISE_APPLICATION_ERROR is inside a WHEN OTHERS or WHEN specific_exception handler, use TRUE. If it is in regular procedural code, FALSE is correct.
πŸ“Š Production Insight
I have seen three separate production systems where every RAISE_APPLICATION_ERROR used the default FALSE inside WHEN OTHERS handlers. In each case, the error logs contained thousands of entries with custom messages but zero root cause information. The teams could see that errors occurred but could not determine why. Fixing the third parameter to TRUE immediately transformed the error logs from useless to diagnostic.
🎯 Key Takeaway
RAISE_APPLICATION_ERROR with keep_errors TRUE preserves the original error chain. Without it, the root cause is permanently discarded. Use TRUE inside exception handlers, FALSE in procedural code. A consistent error code scheme enables programmatic handling and log filtering.

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.

propagation_patterns.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
-- ============================================================
-- 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;
/
Mental Model
Four-Layer Exception Architecture
Each layer in the call stack adds a different kind of value. Catching at the wrong layer removes value instead of adding it.
  • 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.
πŸ“Š Production Insight
The most common propagation mistake is developers adding WHEN OTHERS handlers to inner utility functions 'for safety.' Every such handler is a context-destroying checkpoint that prevents the calling procedure from making correct recovery decisions. Utility functions should have zero exception handlers unless they can genuinely recover from the error without caller involvement.
🎯 Key Takeaway
Catch at the level where the recovery decision can be made. Inner functions: no handlers. Procedure level: log and re-raise with context. Batch level: log, mark failed, continue. API boundary: log server-side, return safe message. Catching too early destroys the 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_exception_handlers.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
-- ============================================================
-- 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;
/
πŸ’‘Exception Handler Testing Checklist
  • 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
πŸ“Š Production Insight
The holiday weekend incident was caused by a WHEN OTHERS THEN NULL handler that had existed in the codebase for four years. It had never been tested because the test suite covered only the happy path. A single automated test that forced an exception on the payment processing procedure would have caught it before the first deployment.
🎯 Key Takeaway
Untested exception handlers are the most dangerous code in production. Force every handler in automated tests: verify the correct error code is raised, the log entry has a non-empty stack, and batch processing continues past failures. If a handler has never been triggered in a test, it has never been validated.
πŸ—‚ Exception Handling Patterns Comparison
When to use each pattern, what it preserves, and what it costs
PatternCatchesPreserves Original StackBest UseRisk if Misused
WHEN NO_DATA_FOUNDORA-01403 onlyYes (propagates unchanged if re-raised with RAISE)Specific handling for missing data β€” return default or raise business errorReturning a default when missing data is a configuration bug hides the problem
WHEN TOO_MANY_ROWSORA-01422 onlyYes (propagates unchanged if re-raised with RAISE)Specific handling for duplicate data β€” always raise a diagnostic errorReturning a default when duplicates exist hides a data model bug
WHEN OTHERS + RAISEAll unhandled exceptionsYes β€” RAISE propagates the original exception unchangedProcedure-level default handler: log the error, then let it propagateNone β€” this is the safest generic handler
WHEN OTHERS + continueAll exceptions in a loop iterationYes β€” captured via FORMAT_ERROR_STACK into local variables before SQLBatch transaction-level: log failure, mark record FAILED, process nextMust capture FORMAT_ERROR_STACK first or the log captures wrong context
RAISE_APPLICATION_ERROR with TRUEN/A β€” raises a new exceptionYes β€” TRUE preserves the original error chain in the stackRe-raising a caught exception with additional business contextNone β€” this is the correct pattern for context-enriched re-raises
RAISE_APPLICATION_ERROR with FALSEN/A β€” raises a new exceptionNo β€” FALSE replaces the entire stackRaising a new error unrelated to a prior exception (validation, auth)Using FALSE inside an exception handler permanently discards the root cause
WHEN OTHERS THEN NULLAll exceptionsNo β€” exception is silently discardedNever appropriate in production codeConverts 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

    βœ•Using WHEN OTHERS THEN NULL anywhere in production code
    Symptom

    Errors are silently swallowed. No log entries are created. The system reports success while producing incorrect results. The failures are discovered hours or days later by customers, reconciliation processes, or downstream systems β€” never by the application itself.

    Fix

    Remove every WHEN OTHERS THEN NULL from the codebase. Replace with WHEN OTHERS that captures FORMAT_ERROR_STACK and either re-raises with RAISE (procedure level) or logs and continues (batch level). Grep the codebase: SELECT name, line, text FROM all_source WHERE type = 'PACKAGE BODY' AND UPPER(text) LIKE '%THEN NULL%' ORDER BY name, line.

    βœ•Executing SQL before capturing FORMAT_ERROR_STACK in the exception handler
    Symptom

    Error log entries exist but contain empty or incorrect error stacks. The cleanup INSERT or status UPDATE executed before FORMAT_ERROR_STACK overwrote the error buffer. During incident response, the team sees log entries and concludes the errors were non-diagnostic.

    Fix

    Restructure every exception handler to follow the first-statement rule: capture FORMAT_ERROR_STACK, FORMAT_ERROR_BACKTRACE, and SQLCODE into local variables as the absolute first operation. Use a nested DECLARE block inside WHEN OTHERS for the local variable declarations. No SQL can precede the capture.

    βœ•Using RAISE_APPLICATION_ERROR without the third parameter TRUE when re-raising a caught exception
    Symptom

    Error logs show custom error messages but no root cause information. The original ORA-XXXXX error code and its ORA-06512 call chain are permanently discarded. Post-mortem debugging can determine that an error occurred but not why.

    Fix

    Use TRUE as the third parameter (keep_errors) whenever RAISE_APPLICATION_ERROR is called inside an exception handler. This preserves the original error chain. Use FALSE (the default) only when raising a new error in procedural code β€” not inside exception handlers.

    βœ•Using a single error code (-20000) for every custom error
    Symptom

    Calling code cannot distinguish validation failures from not-found errors from authorization failures. Error log analysis cannot filter by error type. Automated alerting treats every error identically. Programmatic error handling at the application layer is impossible.

    Fix

    Define a centralized error code scheme in a single package: -20001 to -20009 for validation, -20010 to -20019 for not-found, -20020 to -20029 for authorization. Document the scheme. Enforce through code review β€” no raw RAISE_APPLICATION_ERROR calls outside the error package.

    βœ•Catching exceptions too early in inner utility functions
    Symptom

    Outer blocks cannot see the specific exception type because the inner function transformed it into a generic custom error. Recovery decisions that depend on the error type β€” returning a default for missing preferences vs. halting for missing configuration β€” cannot be made.

    Fix

    Remove exception handlers from inner utility functions unless they can genuinely recover without caller involvement. Let specific exceptions propagate to the layer that has business context to handle them. Add handlers at the procedure, batch, and API layers β€” not at the utility layer.

    βœ•Error logging procedure does not use PRAGMA AUTONOMOUS_TRANSACTION
    Symptom

    Error log entries disappear when the main transaction rolls back. The INSERT into the error log table is part of the same transaction as the failed operation. Rollback erases the error evidence. The failure appears to have never happened.

    Fix

    Add PRAGMA AUTONOMOUS_TRANSACTION to the error logging procedure. Its COMMIT is independent of the caller's transaction. If the caller rolls back, the error log entry is preserved. This is the only way to ensure error evidence survives transaction failure.

Interview Questions on This Topic

  • QWhat is the correct way to use WHEN OTHERS in PL/SQL?JuniorReveal
    WHEN OTHERS has exactly two correct forms. Form one β€” log and re-raise: capture FORMAT_ERROR_STACK into local variables, log the error using an autonomous transaction procedure, then call RAISE to propagate the original exception. This is the standard handler for procedure-level code. Form two β€” log and continue: capture the error context, log it, mark the failing record as FAILED, increment an error counter, and continue to the next iteration. This is the handler for batch loop-level code. Any other form β€” THEN NULL, THEN RETURN, returning a default β€” silently swallows the exception and is never appropriate in production. WHEN OTHERS THEN NULL is the single most dangerous line of PL/SQL in a production system.
  • QWhy must FORMAT_ERROR_STACK be captured before any SQL in an exception handler?Mid-levelReveal
    Oracle maintains exception context in a session-level buffer that is overwritten by the next SQL statement. If an INSERT, UPDATE, DELETE, COMMIT, or SELECT executes before FORMAT_ERROR_STACK is called, the buffer contains the result of that SQL operation β€” not the original error. The error log captures wrong or empty diagnostic information. The fix is to capture FORMAT_ERROR_STACK, FORMAT_ERROR_BACKTRACE, and SQLCODE into local variables as the absolute first operation in the handler. PL/SQL variable assignment reads the buffer without overwriting it, so the capture is safe. After capture, any SQL can execute because the error context is preserved in local variables.
  • QWhat does the keep_errors parameter in RAISE_APPLICATION_ERROR do and when should it be TRUE?Mid-levelReveal
    The third parameter β€” called keep_errors in Oracle documentation β€” controls whether the existing error stack is preserved. TRUE preserves the original error chain: the custom error is appended on top of whatever errors already exist. The caller sees both the business context and the root cause. FALSE β€” the default β€” replaces the entire stack. Only the custom error remains. Use TRUE inside exception handlers when re-raising a caught exception with additional context. Use FALSE in procedural code when raising a new error unrelated to a previously caught exception β€” validation failures, business rule checks, authorization denials. Using FALSE inside a WHEN OTHERS handler permanently discards the root cause.
  • QHow do you design exception handling for a PL/SQL batch processing system?SeniorReveal
    Four layers. Layer one β€” inner functions: no exception handlers. Let specific exceptions propagate to the layer with business context. Layer two β€” procedure level: catch specific exceptions with context-rich handlers (NO_DATA_FOUND raises a custom not-found error, TOO_MANY_ROWS raises a diagnostic error). Catch WHEN OTHERS to log and re-raise. Layer three β€” batch loop level: catch WHEN OTHERS inside the loop body. Capture FORMAT_ERROR_STACK first, log the error using an autonomous transaction procedure, mark the failing record FAILED with the error context, increment an error counter, and continue to the next iteration. After the loop, check the error rate β€” if it exceeds a threshold (typically 5%), raise an alert. Layer four β€” post-batch reconciliation: compare expected vs. actual transaction counts. A batch that reports zero errors but processed fewer transactions than expected indicates a silent failure β€” possibly from a WHEN OTHERS THEN NULL that the audit missed.
  • QWhat is the difference between RAISE and RAISE_APPLICATION_ERROR in an exception handler?Mid-levelReveal
    RAISE with no arguments re-raises the current exception unchanged β€” same error code, same message, same stack. It is the simplest and safest way to propagate an exception after logging. The caller sees the original Oracle error as if the handler did not exist. RAISE_APPLICATION_ERROR creates a new exception with a custom error code (-20000 to -20999) and a custom message. With keep_errors TRUE, it appends the new error on top of the existing stack β€” both the custom context and the original error are visible. With keep_errors FALSE (the default), it replaces the stack entirely. Use RAISE when you want to propagate the original error unchanged. Use RAISE_APPLICATION_ERROR with TRUE when you want to add business context while preserving the root cause.
  • QWhy should the error logging procedure use PRAGMA AUTONOMOUS_TRANSACTION?SeniorReveal
    Without PRAGMA AUTONOMOUS_TRANSACTION, the INSERT into the error log table is part of the same transaction as the failed operation. If the caller issues a ROLLBACK β€” which is common after a failed operation β€” the error log INSERT is also rolled back. The error evidence is destroyed. The failure appears to have never happened. With PRAGMA AUTONOMOUS_TRANSACTION, the logging procedure runs in its own independent transaction. Its COMMIT affects only the error log INSERT. The caller's subsequent ROLLBACK does not affect the log entry. This is the only way to guarantee that error evidence survives transaction failure. Every error logging procedure in a production PL/SQL system must use PRAGMA AUTONOMOUS_TRANSACTION.

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.

πŸ”₯
Naren Founder & Author

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

← PreviousHow to Read and Understand the Oracle Error Stack (ORA-XXXX + ORA-06512)Next β†’ORA-01403: No Data Found – Causes, Fixes & Prevention
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged