Skip to content
Homeβ€Ί Databaseβ€Ί How to Use RAISE_APPLICATION_ERROR Properly in Oracle

How to Use RAISE_APPLICATION_ERROR Properly in Oracle

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: PL/SQL β†’ Topic 21 of 27
When and how to use RAISE_APPLICATION_ERROR to replace generic ORA-06512 messages with meaningful errors.
βš™οΈ Intermediate β€” basic Database knowledge assumed
In this tutorial, you'll learn
When and how to use RAISE_APPLICATION_ERROR to replace generic ORA-06512 messages with meaningful errors.
  • RAISE_APPLICATION_ERROR replaces generic ORA-06512 with custom error codes and descriptive messages β€” it is the contract between the database layer and the application layer
  • Error codes must be in the -20000 to -20999 range β€” values outside this range raise ORA-21000 instead of your intended custom error
  • keep_error_stack=TRUE preserves the original error context β€” FALSE (the default) replaces it β€” default to TRUE in nested handlers
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • RAISE_APPLICATION_ERROR replaces generic ORA-06512 stack traces with custom error codes and descriptive messages that the application can act on
  • Error numbers must be in the -20000 to -20999 range β€” codes outside this range raise ORA-21000 (error number argument to raise_application_error is out of range)
  • The second parameter is the error message β€” up to 2048 bytes total, though most client tools display roughly the first 512 bytes by default
  • The third parameter (stack) controls whether the custom error replaces the error stack (FALSE, the default) or chains onto the existing stack (TRUE) β€” defaulting to TRUE in nested handlers preserves diagnostic context
  • Production insight: the majority of production debugging time lost to ORA-06512 could be recovered by replacing generic WHEN OTHERS handlers with specific RAISE_APPLICATION_ERROR calls that include the original SQLCODE and relevant entity IDs
  • Biggest mistake: using RAISE_APPLICATION_ERROR for validation errors without including the invalid value in the message β€” the error tells you WHAT failed but not WHY
Production IncidentGeneric ORA-06512 Hid a Duplicate Order for 3 Weeks β€” $2.4M in Double ChargesA payment processing procedure raised generic ORA-06512 on duplicate orders. The application logged the error and retried. Three weeks of double charges accumulated before the root cause was identified.
SymptomThe payment processing application was logging intermittent ORA-06512 errors from the payment procedure. The application team's error logs showed only: ORA-06512: at "APP_USER.PAYMENT_PKG", line 847. No custom error code, no business context, no indication of what type of failure occurred. The team could not determine whether the error was transient (network, lock timeout) or permanent (duplicate order, invalid card).
AssumptionThe team assumed ORA-06512 indicated a transient infrastructure error β€” a network hiccup or a database lock timeout. They configured automatic retry with exponential backoff, added the error to a 'known transient errors' suppression list in their monitoring system, and moved on to other priorities. No one checked the actual exception type because the error message provided no clue, and examining the procedure source at line 847 required database access that the application team did not have.
Root causeThe payment procedure had a WHEN OTHERS exception handler that caught all exceptions and re-raised with RAISE_APPLICATION_ERROR(-20001, 'Payment processing failed'). This handler caught everything β€” unique constraint violations, no-data-found errors, privilege errors, and deadlocks β€” and replaced all of them with the same generic error code and the same useless message. When a customer submitted the same order twice, Oracle raised ORA-00001 (unique constraint violated) on the payment_transactions table. The WHEN OTHERS handler caught ORA-00001, discarded it, and re-raised as ORA-20001 with no context. The application saw ORA-20001, which was on the retry list, and retried β€” inserting the payment via a different code path that did not hit the unique constraint. The customer was charged twice. This pattern repeated across thousands of orders over three weeks, accumulating $2.4M in double charges before a finance reconciliation flagged the discrepancy.
FixReplaced the generic WHEN OTHERS handler with specific exception handlers for each known failure mode. Each handler raises a distinct custom error code with full diagnostic context: - DUP_VAL_ON_INDEX β†’ -20010: 'DUPLICATE_ORDER: order_id=' || v_order_id || ' constraint=' || SQLERRM - NO_DATA_FOUND β†’ -20011: 'ORDER_NOT_FOUND: order_id=' || v_order_id - Custom insufficient funds check β†’ -20012: 'INSUFFICIENT_FUNDS: account=' || v_account_id || ' balance=' || v_balance || ' amount=' || v_amount The application error mapper was updated to map each code to a specific recovery action: -20010 returns 'order already processed' to the caller (no retry), -20011 returns 'order not found' (no retry), -20012 prompts for a different payment method (no retry), and -20050 (deadlock) retries with backoff. Added the original SQLCODE and SQLERRM to every custom error message so that even the WHEN OTHERS fallback handler preserves diagnostic context for unknown errors.
Key Lesson
Never use a single generic error code for all exceptions β€” each distinct failure mode needs a distinct error code so the application can respond correctlyWHEN OTHERS without specific re-raising and context preservation is the root cause of the majority of un-debuggable production errors in PL/SQL systemsAlways include the original SQLCODE, SQLERRM, and relevant business context (entity IDs, values, states) in the RAISE_APPLICATION_ERROR messageThe application must map custom error codes to specific recovery actions β€” generic errors force blind retries that can cause data corruption
Production Debug GuideFrom generic ORA-06512 to root cause identification
ORA-06512 with no custom error code — only a procedure name and line number→The procedure either has no exception handler (the Oracle error propagates as-is), or has a WHEN OTHERS that uses bare RAISE instead of RAISE_APPLICATION_ERROR. Check the procedure source at the reported line number. If the line is inside a WHEN OTHERS handler, the handler is re-raising without custom context. Fix by adding RAISE_APPLICATION_ERROR with a specific code and a message that includes SQLCODE and SQLERRM.
ORA-20001 through ORA-20999 with a custom message→A RAISE_APPLICATION_ERROR was called with a custom code. Parse the message for diagnostic context: entity IDs, error type prefix, original SQLCODE. Map the error code to the application's error registry to determine the failure mode, severity, and recommended recovery action.
Custom error message appears truncated — critical details are missing→Most client tools display roughly the first 512 bytes of the error message. The full 2048-byte message is available in the error stack: Java via SQLException.getMessage(), Python via the exception args, .NET via OracleException.Message. Retrieve the full stack programmatically. If the message still lacks context, the RAISE_APPLICATION_ERROR call needs to front-load critical details (error type, entity ID, invalid value) in the first 512 bytes.
RAISE_APPLICATION_ERROR call exists in source but the caller sees generic ORA-06512 instead of the custom code→Check the error number. If it is outside the -20000 to -20999 range, Oracle raises ORA-21000 instead of your custom error, and the RAISE_APPLICATION_ERROR itself becomes the error. Verify the error number is within the valid range. Also check whether an outer exception handler is catching and re-raising with RAISE (without RAISE_APPLICATION_ERROR), which discards the custom code.
Error stack shows multiple ORA-20xxx errors at different levels — nested RAISE_APPLICATION_ERROR calls→Each layer in the PL/SQL call chain added its own RAISE_APPLICATION_ERROR with stack=TRUE. Read the stack from bottom to top: the deepest ORA-20xxx is the root cause error, each higher ORA-20xxx adds context about what the calling layer was doing when it caught the error. If stack=FALSE was used at any level, the errors below that level are lost.

RAISE_APPLICATION_ERROR is a built-in PL/SQL procedure that raises a user-defined exception with a custom error code and message. It exists because Oracle's default exception propagation produces ORA-06512 stack traces that are useful for database developers reading source code but useless for application developers, operations teams, and monitoring systems that need to understand what happened and how to respond.

The procedure accepts three parameters: an error number in the -20000 to -20999 range, a message string up to 2048 bytes, and an optional boolean that controls whether the custom error replaces or chains onto the existing error stack. When called, it terminates the current PL/SQL block immediately β€” no code after the call executes β€” and propagates the error to the caller. The caller can be another PL/SQL block, a JDBC application, a Python script, or a SQL*Plus session.

The deeper insight is that RAISE_APPLICATION_ERROR is not just an error-handling utility β€” it is a contract between the database layer and the application layer. A well-designed PL/SQL API uses custom error codes to communicate specific failure modes: -20001 for entity not found, -20002 for duplicate submission, -20050 for deadlock detected (retryable), -20101 for insufficient funds. The application maps each custom error code to a specific recovery action β€” display an error, retry with backoff, redirect to a different payment method, or escalate to operations. Without this contract, the application is forced to parse error message strings or treat every database error as a generic failure, which leads to blind retries, silent data corruption, and the kind of incident described below.

RAISE_APPLICATION_ERROR Syntax and Parameters

RAISE_APPLICATION_ERROR accepts three parameters. Only the first two are required. Each parameter has a strict constraint, and violating the constraint produces behavior that is easy to misdiagnose.

The first parameter, error_number, must be an integer in the range -20000 to -20999 inclusive. This range provides 1000 unique error codes for application-specific errors. Oracle reserves all other negative error numbers for its own internal errors. If you pass a number outside this range β€” say -30001 or -19999 β€” Oracle raises ORA-21000: error number argument to raise_application_error of -30001 is out of range. The RAISE_APPLICATION_ERROR call itself fails, and the caller sees ORA-21000 instead of your intended custom error. This is not a 'silent ignore' β€” it is a different error entirely, and it is confusing because the error message references raise_application_error rather than your business logic.

The second parameter, message, is a VARCHAR2 string up to 2048 bytes. However, most client tools β€” SQL*Plus, SQL Developer, JDBC's default SQLException.getMessage() β€” display roughly the first 512 bytes. The full message is accessible through the error stack APIs (DBMS_UTILITY.FORMAT_ERROR_STACK in PL/SQL, the full exception object in application languages). The practical rule is to front-load the most critical diagnostic information β€” error type, entity ID, invalid value, original SQLCODE β€” in the first 512 bytes.

The third parameter, keep_error_stack (commonly called 'stack'), is a BOOLEAN that defaults to FALSE. When FALSE, the custom error replaces the entire error stack β€” the original Oracle error, its line number, and any intermediate errors are discarded. When TRUE, the custom error is chained onto the top of the existing stack β€” the original error and all intermediate context are preserved. This parameter has the most impact on production debuggability. Using FALSE in a nested handler destroys the original error; using TRUE preserves it.

io/thecodeforge/exceptions/raise_application_error_basics.sql Β· SQL
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
-- Syntax
-- RAISE_APPLICATION_ERROR(error_number, message [, keep_error_stack]);
--
-- error_number:     INTEGER, must be -20000 to -20999
-- message:          VARCHAR2, up to 2048 bytes
-- keep_error_stack: BOOLEAN, default FALSE
--                   FALSE = replace stack, TRUE = chain onto stack

-- Example 1: Simple custom error with diagnostic context
CREATE OR REPLACE PROCEDURE app_schema.validate_order(
  p_order_id IN NUMBER
) IS
  v_count NUMBER;
BEGIN
  IF p_order_id IS NULL THEN
    RAISE_APPLICATION_ERROR(
      -20001,
      'ORDER_NOT_FOUND: order_id is NULL β€” caller must provide a valid order_id'
    );
  END IF;

  SELECT COUNT(*)
  INTO v_count
  FROM data_owner.orders
  WHERE order_id = p_order_id;

  IF v_count = 0 THEN
    RAISE_APPLICATION_ERROR(
      -20001,
      'ORDER_NOT_FOUND: order_id=' || p_order_id || ' does not exist in the orders table'
    );
  END IF;
END validate_order;
/

-- Example 2: Re-raising with stack preservation (keep_error_stack = TRUE)
CREATE OR REPLACE PROCEDURE app_schema.process_payment(
  p_order_id IN NUMBER
) IS
BEGIN
  app_schema.payment_engine.charge(p_order_id);
EXCEPTION
  WHEN OTHERS THEN
    -- Chain the custom error onto the existing stack
    -- The caller sees BOTH the custom code AND the original error
    RAISE_APPLICATION_ERROR(
      -20010,
      'PAYMENT_FAILED: order_id=' || p_order_id
      || ' | original_error=' || SQLCODE || ': ' || SQLERRM,
      TRUE  -- chain onto existing stack
    );
END process_payment;
/

-- Example 3: What the caller sees with stack=FALSE vs. stack=TRUE
--
-- stack=FALSE (default):
--   ORA-20010: PAYMENT_FAILED: order_id=12345 | original_error=-1: ...
--   ORA-06512: at "APP_SCHEMA.PROCESS_PAYMENT", line 12
--   (The original ORA-00001 and its location are GONE)
--
-- stack=TRUE:
--   ORA-20010: PAYMENT_FAILED: order_id=12345 | original_error=-1: ...
--   ORA-06512: at "APP_SCHEMA.PROCESS_PAYMENT", line 12
--   ORA-00001: unique constraint (DATA_OWNER.ORDERS_UK) violated
--   ORA-06512: at "APP_SCHEMA.PAYMENT_ENGINE", line 89
--   (The original ORA-00001 and its exact location are PRESERVED)

-- Example 4: Error number outside valid range
BEGIN
  RAISE_APPLICATION_ERROR(-30001, 'This will not work');
  -- Oracle raises:
  -- ORA-21000: error number argument to raise_application_error
  --            of -30001 is out of range
  -- Your intended error is never raised
END;
/
Mental Model
RAISE_APPLICATION_ERROR Parameter Rules
Three parameters control what the caller sees β€” each has a strict constraint that produces confusing behavior if violated.
  • error_number: must be -20000 to -20999 β€” values outside this range raise ORA-21000 instead of your custom error
  • message: up to 2048 bytes β€” but only ~512 bytes are displayed by most client tools; front-load critical info
  • keep_error_stack: TRUE chains onto existing stack (preserves original error), FALSE replaces it (original error lost)
  • Default for keep_error_stack is FALSE β€” this means nested handlers LOSE the original error unless you explicitly pass TRUE
  • Each call to RAISE_APPLICATION_ERROR terminates the current PL/SQL block immediately β€” no code after the call executes
πŸ“Š Production Insight
Error codes outside -20000 to -20999 do not silently degrade β€” they raise ORA-21000, which is equally confusing.
The caller sees an error about raise_application_error itself instead of your business logic error.
Rule: validate your error code is in the valid range β€” and default to keep_error_stack=TRUE in nested handlers to preserve diagnostic context.
🎯 Key Takeaway
RAISE_APPLICATION_ERROR has three parameters: error number (-20000 to -20999), message (up to 2048 bytes), and keep_error_stack (boolean).
keep_error_stack=TRUE preserves the original error context β€” FALSE (the default) replaces it entirely.
Bottom line: if your error code is outside the valid range, you get ORA-21000 instead of your custom error β€” always validate the range.

Error Number Strategy: Designing a Custom Error Code System

A well-designed error code system maps each code to a specific failure mode, a severity level, and a recovery action. Without a systematic approach, teams assign error codes ad hoc β€” typically starting at -20001 and incrementing β€” leading to collisions across modules, gaps in coverage, and code that is unmaintainable after the original developer leaves.

The recommended approach is to partition the -20000 to -20999 range into reserved blocks for each module or domain. For example: -20000 to -20099 for the orders module, -20100 to -20199 for payments, -20200 to -20299 for inventory, -20900 to -20999 reserved for infrastructure (deadlock, timeout, unexpected). Within each block, use a consistent sub-pattern: codes ending in 01-09 for 'not found' errors, 10-19 for 'duplicate' errors, 20-29 for 'invalid state' errors, 30-39 for 'business rule violation' errors. This convention makes error codes self-documenting β€” any engineer can look at -20112 and know it is a payments module duplicate error without checking a table.

Each error code should have a registered definition in a central error registry table. The registry stores the code, a short machine-readable name (DUPLICATE_ORDER), a human-readable description, a severity level (for alerting thresholds), and a recommended recovery action (for the application layer). This registry serves as the contract between the database team and the application team β€” changes to it must be coordinated and versioned.

The error message format should be consistent and parseable by both humans and automated log analysis tools. A proven format is: ERROR_NAME: key1=value1 key2=value2 | original=SQLERRM. For example: DUPLICATE_ORDER: order_id=12345 customer_id=67890 | original=ORA-00001: unique constraint (ORDERS_UK) violated. This format allows grep, structured log parsers, and monitoring systems to extract specific fields without fragile string parsing.

io/thecodeforge/exceptions/error_registry.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
-- Error registry table β€” single source of truth for all custom error codes
CREATE TABLE app_schema.error_registry (
  error_code      NUMBER(6)     PRIMARY KEY
    CONSTRAINT chk_error_code CHECK (error_code BETWEEN -20999 AND -20000),
  error_name      VARCHAR2(60)  NOT NULL UNIQUE,
  description     VARCHAR2(500) NOT NULL,
  severity        VARCHAR2(10)  NOT NULL
    CONSTRAINT chk_severity CHECK (severity IN ('LOW','MEDIUM','HIGH','CRITICAL')),
  recovery_action VARCHAR2(200) NOT NULL,
  retryable       VARCHAR2(1)   DEFAULT 'N' NOT NULL
    CONSTRAINT chk_retryable CHECK (retryable IN ('Y','N')),
  module          VARCHAR2(30)  NOT NULL,
  created_date    DATE          DEFAULT SYSDATE NOT NULL
);

-- Order module errors: -20000 to -20099
INSERT INTO app_schema.error_registry VALUES (
  -20001, 'ORDER_NOT_FOUND',
  'The specified order does not exist in the database',
  'MEDIUM', 'Verify the order_id and re-submit with a valid ID', 'N',
  'ORDERS', SYSDATE
);
INSERT INTO app_schema.error_registry VALUES (
  -20010, 'DUPLICATE_ORDER',
  'An order with this ID or idempotency key already exists β€” duplicate submission detected',
  'HIGH', 'Check if the order was already processed; do NOT retry', 'N',
  'ORDERS', SYSDATE
);
INSERT INTO app_schema.error_registry VALUES (
  -20020, 'ORDER_INVALID_STATE',
  'The order is in a state that does not allow this operation',
  'MEDIUM', 'Check order status before attempting this operation', 'N',
  'ORDERS', SYSDATE
);
INSERT INTO app_schema.error_registry VALUES (
  -20030, 'ORDER_BUSINESS_RULE',
  'A business rule prevents this order operation',
  'MEDIUM', 'Review the business rule details in the error message', 'N',
  'ORDERS', SYSDATE
);

-- Payment module errors: -20100 to -20199
INSERT INTO app_schema.error_registry VALUES (
  -20101, 'PAYMENT_INSUFFICIENT_FUNDS',
  'The payment method has insufficient funds for this transaction',
  'MEDIUM', 'Prompt user for a different payment method', 'N',
  'PAYMENTS', SYSDATE
);
INSERT INTO app_schema.error_registry VALUES (
  -20102, 'PAYMENT_CARD_EXPIRED',
  'The payment card has expired',
  'MEDIUM', 'Prompt user for updated card details', 'N',
  'PAYMENTS', SYSDATE
);
INSERT INTO app_schema.error_registry VALUES (
  -20110, 'PAYMENT_DUPLICATE',
  'This payment has already been processed β€” duplicate charge attempt',
  'HIGH', 'Do NOT retry; verify original payment was successful', 'N',
  'PAYMENTS', SYSDATE
);

-- Infrastructure / retryable errors: -20900 to -20999
INSERT INTO app_schema.error_registry VALUES (
  -20950, 'DEADLOCK_DETECTED',
  'A deadlock was detected β€” the operation can be safely retried',
  'LOW', 'Retry with exponential backoff', 'Y',
  'INFRASTRUCTURE', SYSDATE
);
INSERT INTO app_schema.error_registry VALUES (
  -20951, 'LOCK_TIMEOUT',
  'A lock wait timeout occurred β€” the operation can be safely retried',
  'MEDIUM', 'Retry with exponential backoff', 'Y',
  'INFRASTRUCTURE', SYSDATE
);
INSERT INTO app_schema.error_registry VALUES (
  -20999, 'UNEXPECTED_ERROR',
  'An unexpected error occurred that does not match any known failure mode',
  'CRITICAL', 'Escalate to operations team with full error stack', 'N',
  'INFRASTRUCTURE', SYSDATE
);

COMMIT;

-- Centralized raise procedure β€” enforces consistent message format
CREATE OR REPLACE PROCEDURE app_schema.raise_app_error(
  p_error_code IN NUMBER,
  p_context    IN VARCHAR2 DEFAULT NULL,
  p_keep_stack IN BOOLEAN  DEFAULT TRUE
) IS
  v_rec     app_schema.error_registry%ROWTYPE;
  v_message VARCHAR2(2048);
BEGIN
  BEGIN
    SELECT * INTO v_rec
    FROM app_schema.error_registry
    WHERE error_code = p_error_code;
  EXCEPTION
    WHEN NO_DATA_FOUND THEN
      -- Unregistered code β€” raise with a warning prefix
      v_message := 'UNREGISTERED_ERROR: code=' || p_error_code;
      IF p_context IS NOT NULL THEN
        v_message := v_message || ' | ' || p_context;
      END IF;
      RAISE_APPLICATION_ERROR(p_error_code, v_message, p_keep_stack);
      RETURN;
  END;

  -- Build a structured, parseable message
  v_message := v_rec.error_name || ': ' || v_rec.description;
  IF p_context IS NOT NULL THEN
    v_message := v_message || ' | ' || p_context;
  END IF;

  RAISE_APPLICATION_ERROR(p_error_code, v_message, p_keep_stack);
END raise_app_error;
/

-- Usage in application code
CREATE OR REPLACE PROCEDURE app_schema.create_order(
  p_customer_id IN NUMBER,
  p_product_id  IN NUMBER
) IS
BEGIN
  INSERT INTO data_owner.orders (order_id, customer_id, product_id)
  VALUES (data_owner.orders_seq.NEXTVAL, p_customer_id, p_product_id);
EXCEPTION
  WHEN DUP_VAL_ON_INDEX THEN
    app_schema.raise_app_error(
      -20010,
      'customer_id=' || p_customer_id || ' product_id=' || p_product_id
      || ' | original=' || SQLERRM
    );
  WHEN OTHERS THEN
    app_schema.raise_app_error(
      -20999,
      'customer_id=' || p_customer_id || ' SQLCODE=' || SQLCODE
      || ' | original=' || SQLERRM
    );
END create_order;
/
πŸ’‘Error Code Range Allocation Strategy
  • Partition the -20000 to -20999 range: reserve 100 codes per module (-20000 to -20099 for orders, -20100 to -20199 for payments, etc.)
  • Within each block, use consistent sub-ranges: XX01-XX09 for not-found, XX10-XX19 for duplicates, XX20-XX29 for invalid state, XX30-XX39 for business rules
  • Reserve -20900 to -20999 for infrastructure/retryable errors (deadlock, timeout, unexpected)
  • Maintain a central error_registry table β€” every code must have a definition before it appears in production code
  • The registry is a contract between database and application teams β€” version it, review changes, deploy atomically
πŸ“Š Production Insight
Ad hoc error code assignment β€” starting at -20001 and incrementing β€” leads to collisions, gaps, and unmaintainable code within months.
A systematic range allocation makes error codes self-documenting and prevents cross-module collisions.
Rule: if your error code is not in the registry, it should not exist in production code β€” enforce this with a pre-deployment check.
🎯 Key Takeaway
Error codes must be systematic: partition ranges per module, use consistent sub-ranges for error types, and maintain a central registry.
The error registry is a shared contract between database and application β€” it maps each code to severity, retryability, and recovery action.
Bottom line: ad hoc error code assignment is technical debt that compounds β€” it will collide, confuse, and cost debugging time proportional to the number of unregistered codes.

Stack Behavior: When to Use keep_error_stack=TRUE

The keep_error_stack parameter controls whether RAISE_APPLICATION_ERROR preserves or replaces the existing error stack. This single boolean has more impact on production debuggability than any other aspect of PL/SQL exception handling.

When keep_error_stack=FALSE (the default), the custom error replaces the entire error stack. The caller sees only the custom error code, the custom message, and the ORA-06512 line where RAISE_APPLICATION_ERROR was called. The original Oracle error β€” ORA-00001, ORA-01403, ORA-00060, whatever triggered the exception handler β€” is gone. The intermediate call chain is gone. The original line number where the error occurred is gone. For debugging, this means the only information available is what the developer chose to include in the custom message.

When keep_error_stack=TRUE, the custom error is chained onto the top of the existing stack. The caller sees the custom error code and message at the top, followed by the complete original error stack β€” the original Oracle error, every ORA-06512 line in the call chain, and any intermediate errors. This provides the full diagnostic context: what the business-level operation was (the custom error), what the database-level failure was (the original error), and exactly where it happened (the line numbers).

The practical rule is straightforward: use keep_error_stack=FALSE only at the outermost API boundary β€” the top-level procedure that the application calls directly β€” where you deliberately want to hide internal implementation details from the caller. Use keep_error_stack=TRUE everywhere else β€” in nested handlers, in utility procedures, in any exception handler that catches and re-raises. The default should be TRUE in your mental model, even though Oracle's default is FALSE.

The most common mistake is using FALSE in a nested handler inside a deep call chain. When an inner procedure raises ORA-00001 and an outer procedure catches it and re-raises with RAISE_APPLICATION_ERROR(-20010, 'Payment failed', FALSE), the original ORA-00001 β€” including which constraint was violated, which table, which columns β€” is permanently lost. The application team sees 'Payment failed' and has no way to determine the root cause without SSH access to the database server and the ability to reproduce the exact scenario.

io/thecodeforge/exceptions/stack_behavior.sql Β· SQL
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
-- Scenario 1: keep_error_stack=FALSE (default) β€” LOSES the original error
CREATE OR REPLACE PROCEDURE app_schema.create_order_v1(
  p_customer_id IN NUMBER,
  p_product_id  IN NUMBER
) IS
BEGIN
  INSERT INTO data_owner.orders (order_id, customer_id, product_id)
  VALUES (data_owner.orders_seq.NEXTVAL, p_customer_id, p_product_id);
EXCEPTION
  WHEN DUP_VAL_ON_INDEX THEN
    -- keep_error_stack=FALSE (implicit default): original ORA-00001 is LOST
    RAISE_APPLICATION_ERROR(
      -20010,
      'DUPLICATE_ORDER: customer_id=' || p_customer_id
      || ' product_id=' || p_product_id
    );
    -- Caller sees:
    --   ORA-20010: DUPLICATE_ORDER: customer_id=100 product_id=200
    --   ORA-06512: at "APP_SCHEMA.CREATE_ORDER_V1", line 12
    -- Original ORA-00001 is NOT in the stack
    -- Which constraint? Which table? Unknown from the stack alone.
END create_order_v1;
/

-- Scenario 2: keep_error_stack=TRUE β€” PRESERVES the original error
CREATE OR REPLACE PROCEDURE app_schema.create_order_v2(
  p_customer_id IN NUMBER,
  p_product_id  IN NUMBER
) IS
BEGIN
  INSERT INTO data_owner.orders (order_id, customer_id, product_id)
  VALUES (data_owner.orders_seq.NEXTVAL, p_customer_id, p_product_id);
EXCEPTION
  WHEN DUP_VAL_ON_INDEX THEN
    -- keep_error_stack=TRUE: original ORA-00001 is PRESERVED
    RAISE_APPLICATION_ERROR(
      -20010,
      'DUPLICATE_ORDER: customer_id=' || p_customer_id
      || ' product_id=' || p_product_id,
      TRUE
    );
    -- Caller sees:
    --   ORA-20010: DUPLICATE_ORDER: customer_id=100 product_id=200
    --   ORA-06512: at "APP_SCHEMA.CREATE_ORDER_V2", line 12
    --   ORA-00001: unique constraint (DATA_OWNER.ORDERS_UK) violated
    --   ORA-06512: at "APP_SCHEMA.CREATE_ORDER_V2", line 5
    -- Original ORA-00001 IS in the stack, including which constraint
END create_order_v2;
/

-- Scenario 3: Nested handlers β€” TRUE at each level preserves full chain
CREATE OR REPLACE PROCEDURE app_schema.process_order(
  p_order_id IN NUMBER
) IS
BEGIN
  app_schema.validate_order(p_order_id);
  app_schema.process_payment(p_order_id);
  app_schema.update_inventory(p_order_id);
EXCEPTION
  WHEN OTHERS THEN
    RAISE_APPLICATION_ERROR(
      -20030,
      'ORDER_PROCESSING_FAILED: order_id=' || p_order_id
      || ' | step_error=' || SQLERRM,
      TRUE  -- preserve the payment/inventory error context
    );
END process_order;
/

-- When process_payment raises -20010 (DUPLICATE_ORDER) with stack=TRUE,
-- and process_order re-raises with -20030 with stack=TRUE,
-- the caller sees the COMPLETE chain:
--
--   ORA-20030: ORDER_PROCESSING_FAILED: order_id=12345 | step_error=...
--   ORA-06512: at "APP_SCHEMA.PROCESS_ORDER", line 14
--   ORA-20010: DUPLICATE_ORDER: customer_id=100 product_id=200
--   ORA-06512: at "APP_SCHEMA.PROCESS_PAYMENT", line 12
--   ORA-00001: unique constraint (DATA_OWNER.ORDERS_UK) violated
--   ORA-06512: at "APP_SCHEMA.PROCESS_PAYMENT", line 5
--
-- Full context: what the business operation was, what the intermediate
-- step was, and what the database-level failure was.
⚠ keep_error_stack=FALSE Dangers
πŸ“Š Production Insight
keep_error_stack=FALSE loses the original error β€” the majority of nested handler bugs trace back to this default.
keep_error_stack=TRUE preserves the full error chain, making the root cause immediately visible in the stack.
Rule: default to TRUE in your coding standards β€” use FALSE only at the outermost API boundary where you consciously want to hide internals.
🎯 Key Takeaway
keep_error_stack=FALSE replaces the error stack β€” the original Oracle error and its location are permanently lost.
keep_error_stack=TRUE chains onto the existing stack β€” full diagnostic context is preserved for every layer in the call chain.
Bottom line: if you cannot find the root cause in the error stack, someone used FALSE in a nested handler β€” fix the handler, not the investigation process.

WHEN OTHERS Anti-Pattern: The Silent Killer

WHEN OTHERS is the most dangerous exception handler in PL/SQL. It catches every exception β€” every Oracle error, every custom error, every unexpected condition β€” including errors the developer never anticipated and cannot safely handle. When combined with RAISE_APPLICATION_ERROR using a single generic error code and a vague message, it destroys the diagnostic value of the original error and makes the application's error-handling logic unreliable.

The anti-pattern takes many forms but the core pattern is always the same: a WHEN OTHERS handler catches all exceptions and re-raises with a generic code and message. RAISE_APPLICATION_ERROR(-20001, 'Something went wrong'). Every distinct failure mode β€” unique constraint violation, table not found, arithmetic overflow, deadlock, out of memory β€” is mapped to the same error code with the same useless message. The application cannot distinguish retryable errors (deadlock, lock timeout) from permanent errors (unique constraint, data integrity violation) from critical errors (out of memory, tablespace full). Blind retries on permanent errors cause data corruption. No retries on transient errors cause unnecessary failures.

The correct approach has three layers. First, catch specific named exceptions that you expect and can handle: DUP_VAL_ON_INDEX, NO_DATA_FOUND, TOO_MANY_ROWS. Each gets its own RAISE_APPLICATION_ERROR with a distinct code and context. Second, catch specific SQLCODE values using WHEN OTHERS with an IF/CASE on SQLCODE for errors that do not have named exceptions (e.g., ORA-00060 for deadlock). Third, the WHEN OTHERS fallback handler β€” which should exist as the last handler β€” must include the original SQLCODE, SQLERRM, and FORMAT_ERROR_BACKTRACE in the custom error message and must use keep_error_stack=TRUE. This ensures that even truly unexpected errors carry full diagnostic context.

An even better approach is a centralized exception handler procedure that maps SQLCODE to a custom error code using the error registry. This eliminates per-procedure exception handling boilerplate, ensures consistent message formatting, and guarantees that every error β€” expected or unexpected β€” is raised with proper context.

io/thecodeforge/exceptions/when_others_antipattern.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
-- ANTI-PATTERN: Generic WHEN OTHERS β€” destroys all diagnostic value
CREATE OR REPLACE PROCEDURE app_schema.create_order_bad(
  p_customer_id IN NUMBER
) IS
BEGIN
  INSERT INTO data_owner.orders (order_id, customer_id)
  VALUES (data_owner.orders_seq.NEXTVAL, p_customer_id);
EXCEPTION
  WHEN OTHERS THEN
    -- WRONG: Every exception becomes the same code and message
    RAISE_APPLICATION_ERROR(-20001, 'Order creation failed');
    -- Caller sees: ORA-20001: Order creation failed
    -- Was it a unique constraint? Privilege error? Deadlock? Tablespace full?
    -- Impossible to determine. Original error is destroyed.
END create_order_bad;
/

-- CORRECT: Specific handlers first, WHEN OTHERS with full context last
CREATE OR REPLACE PROCEDURE app_schema.create_order_good(
  p_customer_id IN NUMBER
) IS
BEGIN
  INSERT INTO data_owner.orders (order_id, customer_id)
  VALUES (data_owner.orders_seq.NEXTVAL, p_customer_id);
EXCEPTION
  WHEN DUP_VAL_ON_INDEX THEN
    -- Specific: duplicate order
    RAISE_APPLICATION_ERROR(
      -20010,
      'DUPLICATE_ORDER: customer_id=' || p_customer_id
      || ' | original=' || SQLERRM,
      TRUE
    );
  WHEN OTHERS THEN
    -- Fallback: unexpected error β€” preserve EVERYTHING
    IF SQLCODE = -60 THEN
      -- Deadlock β€” retryable
      RAISE_APPLICATION_ERROR(
        -20950,
        'DEADLOCK_DETECTED: customer_id=' || p_customer_id
        || ' | original=' || SQLERRM,
        TRUE
      );
    ELSE
      -- Genuinely unexpected β€” log and raise with full context
      app_schema.logger_pkg.error(
        'create_order',
        'Unexpected error: customer_id=' || p_customer_id
        || ' SQLCODE=' || SQLCODE || ' SQLERRM=' || SQLERRM,
        DBMS_UTILITY.FORMAT_ERROR_STACK
        || CHR(10) || DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
      );
      RAISE_APPLICATION_ERROR(
        -20999,
        'UNEXPECTED_ERROR: module=ORDERS op=CREATE_ORDER'
        || ' customer_id=' || p_customer_id
        || ' SQLCODE=' || SQLCODE
        || ' | original=' || SQLERRM,
        TRUE
      );
    END IF;
END create_order_good;
/

-- BEST: Centralized exception handler β€” eliminates per-procedure boilerplate
CREATE OR REPLACE PROCEDURE app_schema.handle_exception(
  p_module    IN VARCHAR2,
  p_operation IN VARCHAR2,
  p_context   IN VARCHAR2 DEFAULT NULL
) IS
  v_sqlcode     NUMBER  := SQLCODE;
  v_sqlerrm     VARCHAR2(512) := SQLERRM;
  v_backtrace   VARCHAR2(4000) := DBMS_UTILITY.FORMAT_ERROR_BACKTRACE;
  v_custom_code NUMBER;
  v_message     VARCHAR2(2048);
BEGIN
  -- Map the original SQLCODE to a custom error code
  v_custom_code := CASE v_sqlcode
    WHEN -1    THEN -20010   -- unique constraint -> duplicate
    WHEN -1403 THEN -20001   -- no data found -> not found
    WHEN -1422 THEN -20020   -- too many rows -> ambiguous state
    WHEN -60   THEN -20950   -- deadlock -> retryable
    WHEN -30006 THEN -20951  -- lock timeout -> retryable
    ELSE -20999              -- everything else -> unexpected
  END;

  -- Build structured, parseable message
  v_message := p_module || '.' || p_operation
    || ': SQLCODE=' || v_sqlcode
    || ' | original=' || v_sqlerrm;
  IF p_context IS NOT NULL THEN
    v_message := v_message || ' | context=' || p_context;
  END IF;

  -- Log before raising (autonomous transaction ensures log survives rollback)
  app_schema.logger_pkg.error(
    p_module || '.' || p_operation,
    v_message,
    DBMS_UTILITY.FORMAT_ERROR_STACK || CHR(10) || v_backtrace
  );

  RAISE_APPLICATION_ERROR(v_custom_code, v_message, TRUE);
END handle_exception;
/

-- Usage: one line replaces the entire exception handler
CREATE OR REPLACE PROCEDURE app_schema.create_order_best(
  p_customer_id IN NUMBER
) IS
BEGIN
  INSERT INTO data_owner.orders (order_id, customer_id)
  VALUES (data_owner.orders_seq.NEXTVAL, p_customer_id);
EXCEPTION
  WHEN OTHERS THEN
    app_schema.handle_exception(
      'ORDERS', 'CREATE_ORDER',
      'customer_id=' || p_customer_id
    );
END create_order_best;
/
⚠ WHEN OTHERS Rules of Engagement
πŸ“Š Production Insight
WHEN OTHERS with a generic RAISE_APPLICATION_ERROR is the number one cause of un-debuggable production errors in PL/SQL.
Every exception becomes the same error code β€” the application cannot distinguish retryable from permanent, and the production team cannot determine the root cause.
Rule: every WHEN OTHERS handler must include SQLCODE and SQLERRM in the custom message β€” the original error is the diagnostic context you will need at 3 AM.
🎯 Key Takeaway
WHEN OTHERS destroys diagnostic value when combined with a generic RAISE_APPLICATION_ERROR and a single error code.
Catch specific exceptions first with distinct codes β€” use WHEN OTHERS only as the final fallback with SQLCODE, SQLERRM, and keep_error_stack=TRUE.
Bottom line: if your WHEN OTHERS handler's error message does not contain the original SQLCODE, your exception handler is actively making debugging harder.

Application-Side Error Code Mapping

The database raises custom error codes through RAISE_APPLICATION_ERROR. The application maps those codes to recovery actions. This mapping is the contract that makes the entire system reliable β€” without it, the application treats every database error as a generic failure and either retries blindly or gives up prematurely.

In Java (JDBC), custom error codes are accessible via SQLException.getErrorCode(). The value matches the error_number parameter of RAISE_APPLICATION_ERROR: -20010 for DUPLICATE_ORDER, -20950 for DEADLOCK_DETECTED, etc. The application reads this integer and dispatches to the appropriate handler. In Python (python-oracledb, cx_Oracle), the code is available via exception.args[0].code. In .NET (ODP.NET), it is OracleException.Number. Every language provides a way to extract the integer code β€” use it.

The application should maintain an error code registry that mirrors the database registry. Each code maps to: a user-facing message (never the raw database message), a severity level (for monitoring and alerting thresholds), a retryable flag (boolean), a maximum retry count (for retryable errors), and a recovery action (display error, redirect, retry, escalate). The application never displays the raw database error to the end user β€” it translates the custom code to a user-friendly message using the registry.

For retryable errors (deadlock, lock timeout), the application retries with exponential backoff and a maximum retry count. For permanent errors (duplicate, invalid state), the application displays the appropriate user-facing message and does not retry. For critical errors (unexpected error, resource exhaustion), the application escalates to the operations team via alerting and displays a generic 'contact support' message to the user. Unregistered error codes β€” codes not in the registry β€” should default to the critical path and escalate, because an unknown error code means the contract is broken.

io/thecodeforge/exceptions/ErrorCodeMapper.java Β· JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
package io.thecodeforge.exceptions;

import java.sql.SQLException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Maps Oracle custom error codes (-20000 to -20999) to application
 * recovery actions. Mirrors the database error_registry table.
 *
 * This class is the application side of the database-application
 * error contract. Changes to the database error_registry must be
 * reflected here β€” deploy atomically.
 */
public final class ErrorCodeMapper {

    public enum Severity { LOW, MEDIUM, HIGH, CRITICAL }
    public enum Action { RETRY, DISPLAY_ERROR, REDIRECT, ESCALATE }

    public record ErrorDefinition(
        String name,
        String userMessage,
        Severity severity,
        Action action,
        boolean retryable,
        int maxRetries
    ) {}

    private static final Map<Integer, ErrorDefinition> REGISTRY =
        new ConcurrentHashMap<>();

    static {
        // Order module: -20000 to -20099
        REGISTRY.put(20001, new ErrorDefinition(
            "ORDER_NOT_FOUND",
            "The requested order could not be found. Please verify the order number.",
            Severity.MEDIUM, Action.DISPLAY_ERROR, false, 0
        ));
        REGISTRY.put(20010, new ErrorDefinition(
            "DUPLICATE_ORDER",
            "This order has already been submitted. Please check your order history.",
            Severity.HIGH, Action.DISPLAY_ERROR, false, 0
        ));
        REGISTRY.put(20020, new ErrorDefinition(
            "ORDER_INVALID_STATE",
            "This order cannot be modified in its current state.",
            Severity.MEDIUM, Action.DISPLAY_ERROR, false, 0
        ));

        // Payment module: -20100 to -20199
        REGISTRY.put(20101, new ErrorDefinition(
            "PAYMENT_INSUFFICIENT_FUNDS",
            "Your payment method has insufficient funds. Please use a different card.",
            Severity.MEDIUM, Action.REDIRECT, false, 0
        ));
        REGISTRY.put(20102, new ErrorDefinition(
            "PAYMENT_CARD_EXPIRED",
            "Your card has expired. Please update your payment details.",
            Severity.MEDIUM, Action.REDIRECT, false, 0
        ));
        REGISTRY.put(20110, new ErrorDefinition(
            "PAYMENT_DUPLICATE",
            "This payment has already been processed.",
            Severity.HIGH, Action.DISPLAY_ERROR, false, 0
        ));

        // Infrastructure / retryable: -20900 to -20999
        REGISTRY.put(20950, new ErrorDefinition(
            "DEADLOCK_DETECTED",
            "A temporary conflict occurred. Retrying automatically.",
            Severity.LOW, Action.RETRY, true, 3
        ));
        REGISTRY.put(20951, new ErrorDefinition(
            "LOCK_TIMEOUT",
            "The operation timed out. Retrying automatically.",
            Severity.MEDIUM, Action.RETRY, true, 3
        ));
        REGISTRY.put(20999, new ErrorDefinition(
            "UNEXPECTED_ERROR",
            "An unexpected error occurred. Our team has been notified.",
            Severity.CRITICAL, Action.ESCALATE, false, 0
        ));
    }

    /**
     * Maps a SQLException to the appropriate error definition.
     * Oracle error codes are negative; the registry uses positive keys.
     * Returns a default ESCALATE definition for unregistered codes.
     */
    public static ErrorDefinition map(SQLException ex) {
        // Oracle returns negative codes; registry uses positive for readability
        int code = Math.abs(ex.getErrorCode());
        ErrorDefinition def = REGISTRY.get(code);

        if (def == null) {
            // Unregistered code β€” contract is broken β€” escalate
            return new ErrorDefinition(
                "UNKNOWN_ERROR",
                "An unexpected error occurred. Our team has been notified.",
                Severity.CRITICAL, Action.ESCALATE, false, 0
            );
        }
        return def;
    }

    /** Convenience: is this error safe to retry? */
    public static boolean isRetryable(SQLException ex) {
        return map(ex).retryable();
    }

    /** Convenience: get the user-facing message for this error. */
    public static String getUserMessage(SQLException ex) {
        return map(ex).userMessage();
    }
}
Mental Model
Database-Application Error Contract
The database raises custom codes. The application maps them to recovery actions. This contract is the foundation of reliable error handling.
  • Database defines error codes in -20000 to -20999 β€” each code maps to exactly one failure mode
  • Application maintains a mirrored registry β€” each code maps to user message, severity, retryable flag, and recovery action
  • Retryable errors (deadlock, timeout) trigger exponential backoff with a maximum retry count
  • Permanent errors (duplicate, invalid state) display the user-facing message and do not retry
  • Unregistered error codes escalate to operations β€” an unknown code means the contract is broken and needs immediate attention
  • The error code integer is the contract β€” never parse the error message string for decision logic
πŸ“Š Production Insight
The database and the application must share the same error code registry.
Changing a code in the database without updating the application breaks the contract β€” errors get misrouted, retries happen on permanent failures, and permanent failures get retried.
Rule: the error code registry is a shared contract β€” version it, review changes across teams, and deploy database and application changes atomically.
🎯 Key Takeaway
The application maps custom error codes to recovery actions β€” this database-application contract is what makes error handling reliable.
Retryable errors get exponential backoff with limits β€” permanent errors display and stop β€” unknown errors escalate immediately.
Bottom line: if your application displays a raw ORA-20001 message to the end user or retries on a duplicate error code, the error contract is broken.

RAISE_APPLICATION_ERROR vs. RAISE vs. Implicit Propagation

Oracle provides three mechanisms for propagating errors from PL/SQL: RAISE (re-raise a named exception), RAISE_APPLICATION_ERROR (raise a custom error with a specific code and message), and implicit propagation (let an unhandled Oracle error propagate without any handler). Each produces a different error code, a different message, and a different stack trace for the caller. Choosing the right mechanism depends on the caller's needs.

RAISE is used to re-raise the current exception unchanged. When you catch an exception in a handler and call RAISE with no arguments, the original exception β€” its error code, its message, its entire stack trace β€” propagates to the caller exactly as if the handler did not exist. Use RAISE when you need to perform some action in the handler (logging, cleanup, setting a flag) but do not need to customize the error for the caller.

RAISE_APPLICATION_ERROR is used when the caller needs a custom error code to determine the recovery action. It replaces the current error (unless keep_error_stack=TRUE) with a code in the -20000 to -20999 range and a descriptive message. Use this at API boundaries β€” the procedures that the application calls directly β€” where the caller needs to distinguish between different failure modes.

Implicit propagation happens when a PL/SQL block has no exception handler for the raised error, or no exception handler at all. The Oracle error propagates as-is β€” ORA-00001 with its constraint name, ORA-01403 with its context β€” through the call stack. Use implicit propagation for internal procedures that are called only by other PL/SQL code and do not need to customize the error.

The decision tree: Is the caller an application that needs a custom error code? Use RAISE_APPLICATION_ERROR. Do you need to log or clean up but not customize the error? Use RAISE. Is the caller another PL/SQL procedure that can handle the raw Oracle error? Use implicit propagation (no handler).

io/thecodeforge/exceptions/raise_comparison.sql Β· SQL
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
-- Mechanism 1: RAISE β€” re-raise the current exception unchanged
CREATE OR REPLACE PROCEDURE app_schema.find_order_raise(
  p_order_id IN NUMBER,
  p_result   OUT data_owner.orders%ROWTYPE
) IS
BEGIN
  SELECT * INTO p_result
  FROM data_owner.orders
  WHERE order_id = p_order_id;
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    -- Log the error, then re-raise unchanged
    app_schema.logger_pkg.warn(
      'find_order',
      'Order not found: order_id=' || p_order_id
    );
    RAISE;  -- Caller sees ORA-01403: no data found (unchanged)
END find_order_raise;
/

-- Mechanism 2: RAISE_APPLICATION_ERROR β€” custom code and message
CREATE OR REPLACE PROCEDURE app_schema.find_order_custom(
  p_order_id IN NUMBER,
  p_result   OUT data_owner.orders%ROWTYPE
) IS
BEGIN
  SELECT * INTO p_result
  FROM data_owner.orders
  WHERE order_id = p_order_id;
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    -- Custom error code and message β€” caller sees ORA-20001
    RAISE_APPLICATION_ERROR(
      -20001,
      'ORDER_NOT_FOUND: order_id=' || p_order_id,
      TRUE
    );
END find_order_custom;
/

-- Mechanism 3: Implicit propagation β€” no handler at all
CREATE OR REPLACE PROCEDURE app_schema.find_order_implicit(
  p_order_id IN NUMBER,
  p_result   OUT data_owner.orders%ROWTYPE
) IS
BEGIN
  SELECT * INTO p_result
  FROM data_owner.orders
  WHERE order_id = p_order_id;
  -- No exception handler
  -- If NO_DATA_FOUND: ORA-01403 propagates as-is to the caller
  -- If any other error: that error propagates as-is to the caller
END find_order_implicit;
/

-- Mechanism 4: Combined β€” log, add context, re-raise with custom code
CREATE OR REPLACE PROCEDURE app_schema.process_order(
  p_order_id IN NUMBER
) IS
BEGIN
  app_schema.validate_order(p_order_id);    -- may raise -20001
  app_schema.process_payment(p_order_id);   -- may raise -20010, -20101
  app_schema.update_inventory(p_order_id);  -- may raise -20201
EXCEPTION
  WHEN OTHERS THEN
    -- Log with full context
    app_schema.logger_pkg.error(
      'process_order',
      'Failed: order_id=' || p_order_id
      || ' SQLCODE=' || SQLCODE
      || ' | ' || SQLERRM,
      DBMS_UTILITY.FORMAT_ERROR_STACK
      || CHR(10) || DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
    );
    -- Re-raise with higher-level context, preserving the full chain
    RAISE_APPLICATION_ERROR(
      -20030,
      'ORDER_PROCESSING_FAILED: order_id=' || p_order_id
      || ' | step_error=' || SQLERRM,
      TRUE
    );
END process_order;
/

-- Decision tree summary:
-- Caller is an application needing a specific error code -> RAISE_APPLICATION_ERROR
-- Need to log/clean up but not change the error            -> RAISE
-- Internal procedure, caller handles raw Oracle errors     -> No handler (implicit)
-- Re-raise with context at each call layer                 -> RAISE_APPLICATION_ERROR(..., TRUE)
Mental Model
Three Error Propagation Mechanisms
Oracle has three ways to propagate errors from PL/SQL β€” each produces a different experience for the caller.
  • RAISE: re-raises the current exception unchanged β€” caller sees the original Oracle error code and message exactly as-is
  • RAISE_APPLICATION_ERROR: raises a custom code and message β€” replaces the original error (FALSE) or chains onto it (TRUE)
  • Implicit propagation: no handler β€” the unhandled Oracle error propagates as-is through the call stack
  • Use RAISE when you log/clean up but do not need to customize β€” the caller gets the original error
  • Use RAISE_APPLICATION_ERROR at API boundaries β€” the caller needs a custom code to determine recovery action
  • Use implicit propagation for internal utilities β€” the calling PL/SQL code handles the raw Oracle error directly
πŸ“Š Production Insight
RAISE re-raises unchanged β€” RAISE_APPLICATION_ERROR replaces or chains with a custom code β€” implicit propagation passes the raw Oracle error.
The wrong choice at an API boundary means the application receives raw Oracle internals it cannot interpret.
Rule: use RAISE_APPLICATION_ERROR at every procedure that an application calls directly β€” use RAISE or implicit propagation for internal PL/SQL-to-PL/SQL calls.
🎯 Key Takeaway
RAISE re-raises the original exception unchanged β€” RAISE_APPLICATION_ERROR replaces it with a custom code and message.
Implicit propagation lets the raw Oracle error pass through when no handler is present.
Bottom line: at API boundaries where the application is the caller, always use RAISE_APPLICATION_ERROR β€” internal PL/SQL code can use RAISE or implicit propagation for simplicity.
πŸ—‚ Error Propagation Mechanisms in Oracle PL/SQL
When to use each mechanism and what the caller receives
MechanismCustom CodeCustom MessagePreserves Original StackBest Use Case
RAISE (no arguments)No β€” original code propagatesNo β€” original message propagatesYes β€” stack is unchangedRe-raise after logging or cleanup; caller can handle the raw Oracle error
RAISE_APPLICATION_ERROR (keep_error_stack=FALSE)Yes β€” -20000 to -20999Yes β€” up to 2048 bytesNo β€” original stack is replacedTop-level API boundary; deliberately hide internal implementation details from external callers
RAISE_APPLICATION_ERROR (keep_error_stack=TRUE)Yes β€” -20000 to -20999Yes β€” up to 2048 bytesYes β€” custom error chained onto original stackNested handlers; add business context while preserving the original Oracle error for debugging
Implicit propagation (no handler)No β€” original Oracle codeNo β€” original Oracle messageYes β€” stack is unchangedInternal utility procedures; calling PL/SQL code handles the raw error directly
Centralized handle_exception procedureYes β€” mapped from SQLCODEYes β€” structured format with contextYes β€” uses keep_error_stack=TRUEConsistent error handling across all procedures; eliminates per-procedure boilerplate

🎯 Key Takeaways

  • RAISE_APPLICATION_ERROR replaces generic ORA-06512 with custom error codes and descriptive messages β€” it is the contract between the database layer and the application layer
  • Error codes must be in the -20000 to -20999 range β€” values outside this range raise ORA-21000 instead of your intended custom error
  • keep_error_stack=TRUE preserves the original error context β€” FALSE (the default) replaces it β€” default to TRUE in nested handlers
  • WHEN OTHERS with a generic RAISE_APPLICATION_ERROR and a single error code is the number one cause of un-debuggable production errors
  • A central error registry table maps each code to severity, retryability, and recovery action β€” the application mirrors this registry for error-to-action mapping
  • Error codes are a shared contract between database and application teams β€” version the registry, review changes, and deploy atomically

⚠ Common Mistakes to Avoid

    βœ•Using a single generic error code for all exceptions
    Symptom

    Every exception from the database is ORA-20001 with a message like 'Error occurred' or 'Operation failed'. The application cannot distinguish retryable errors from permanent errors. Production debugging requires reading procedure source code at the reported line number instead of diagnosing from the error message.

    Fix

    Define a unique error code for each distinct failure mode using the error registry. Include the original SQLCODE and relevant business context (entity IDs, values, states) in the RAISE_APPLICATION_ERROR message. Map each code to a specific recovery action on the application side.

    βœ•Using WHEN OTHERS without including SQLCODE, SQLERRM, and diagnostic context
    Symptom

    The custom error message says 'Something went wrong' or 'Payment failed' with no indication of what the original Oracle error was. The production team cannot determine the root cause without database access and the ability to reproduce the exact scenario.

    Fix

    Every WHEN OTHERS handler must include SQLCODE, SQLERRM, and the relevant business context in the custom message: RAISE_APPLICATION_ERROR(-20999, 'UNEXPECTED: SQLCODE=' || SQLCODE || ' msg=' || SQLERRM || ' context=' || v_context, TRUE). The original error is the diagnostic context that makes the custom error actionable.

    βœ•Using keep_error_stack=FALSE in nested exception handlers
    Symptom

    The error stack shows only the outermost custom error. The original Oracle error (ORA-00001, ORA-01403, ORA-00060) and all intermediate handler context are permanently lost. Debugging requires reading every procedure in the call chain and mentally reconstructing which error could have been raised at the reported line.

    Fix

    Use keep_error_stack=TRUE in all nested handlers. Reserve FALSE for the outermost API entry point where you deliberately want to hide internal implementation details. Make TRUE the default in your team's coding standards.

    βœ•Using error codes outside the -20000 to -20999 range
    Symptom

    RAISE_APPLICATION_ERROR(-30001, 'My custom error') raises ORA-21000 instead of the intended custom error. The caller sees 'error number argument to raise_application_error is out of range' β€” which is confusing because the error references raise_application_error rather than the business operation.

    Fix

    Always use error codes in the -20000 to -20999 range. Add a CHECK constraint or validation in the centralized raise procedure. Consider a pre-deployment scan that searches PL/SQL source for RAISE_APPLICATION_ERROR calls with literal numbers outside the valid range.

    βœ•Displaying raw RAISE_APPLICATION_ERROR messages to end users
    Symptom

    Users see messages like 'DUPLICATE_ORDER: order_id=12345 | original=ORA-00001: unique constraint (ORDERS_UK) violated'. This exposes internal database schema details, constraint names, and implementation information to non-technical users.

    Fix

    Map custom error codes to user-friendly messages on the application side using the error registry. The database error code is the contract; the application translates it to a message appropriate for the audience. Raw database messages should only appear in developer logs, not in user interfaces.

    βœ•Not testing RAISE_APPLICATION_ERROR code paths in automated tests
    Symptom

    Error handling code is never exercised until production. When an error finally occurs, the custom message is wrong, the error code is incorrect, the keep_error_stack parameter is backwards, or the application maps the code to the wrong recovery action.

    Fix

    Write automated tests that trigger each exception path by providing inputs that cause the specific error: invalid IDs for not-found, duplicate data for unique constraint, conflicting states for business rule violations. Assert on the SQLCODE returned, the message content, and the stack behavior. Use utPLSQL or a similar framework. Include integration tests that verify the application's error code mapper returns the correct recovery action for each code.

Interview Questions on This Topic

  • QWhat is RAISE_APPLICATION_ERROR and when would you use it?JuniorReveal
    RAISE_APPLICATION_ERROR is a built-in PL/SQL procedure that raises a user-defined exception with a custom error code (in the -20000 to -20999 range) and a descriptive message (up to 2048 bytes). It replaces generic ORA-06512 stack traces with meaningful error codes that the application can interpret and act on. Use it at API boundaries β€” the procedures that the application calls directly β€” where the caller needs a specific error code to determine the recovery action (retry, display error, redirect, escalate). Each failure mode should have a distinct code, and the message should include the original SQLCODE and relevant business context (entity IDs, values, states) for debugging.
  • QWhat does the keep_error_stack parameter do, and when should you use TRUE vs. FALSE?Mid-levelReveal
    The keep_error_stack parameter (third parameter, boolean, default FALSE) controls whether the custom error replaces or chains onto the existing error stack. When FALSE, the custom error replaces the entire stack β€” the original Oracle error, its line number, and all intermediate context are permanently discarded. When TRUE, the custom error is chained onto the top of the existing stack, preserving the full original error chain for debugging. Use TRUE in all nested handlers where the original error provides diagnostic value. Use FALSE only at the outermost API boundary where you deliberately want to hide internal implementation details from the external caller. The default should be TRUE in your team's coding standards, even though Oracle's default is FALSE.
  • QWhy is WHEN OTHERS with a generic RAISE_APPLICATION_ERROR considered an anti-pattern, and what is the correct approach?Mid-levelReveal
    WHEN OTHERS catches every exception β€” including ones the developer did not anticipate. When combined with a generic RAISE_APPLICATION_ERROR that uses a single error code and a vague message like 'Operation failed', it destroys the diagnostic value of the original error. The application cannot distinguish retryable errors (deadlock, timeout) from permanent errors (duplicate, constraint violation), leading to blind retries that can cause data corruption. The correct approach has three layers: first, catch specific named exceptions (DUP_VAL_ON_INDEX, NO_DATA_FOUND) with distinct error codes. Second, in WHEN OTHERS, check SQLCODE for known cases (deadlock = -60) and raise with the appropriate retryable code. Third, the fallback WHEN OTHERS handler must include SQLCODE, SQLERRM, and FORMAT_ERROR_BACKTRACE in the custom message and use keep_error_stack=TRUE. A centralized exception handler procedure that maps SQLCODE to custom codes eliminates per-procedure boilerplate.
  • QHow would you design a custom error code system for a large multi-module application?SeniorReveal
    Partition the -20000 to -20999 range into blocks of 100 per module: -20000 to -20099 for orders, -20100 to -20199 for payments, -20200 to -20299 for inventory, -20900 to -20999 for infrastructure. Within each block, use consistent sub-ranges for error types: XX01-XX09 for not-found, XX10-XX19 for duplicates, XX20-XX29 for invalid state, XX30-XX39 for business rules. Maintain a central error_registry table with each code's name, description, severity, retryable flag, and recovery action. The application mirrors this registry and maps each code to the appropriate handler. Build a centralized raise_app_error procedure that looks up the registry and formats the message consistently. Use a pre-deployment check that verifies every RAISE_APPLICATION_ERROR call in the codebase references a code that exists in the registry.
  • QHow do you ensure the database and application error registries stay in sync, and what happens when they diverge?SeniorReveal
    The error registry is a shared contract between the database team and the application team. Version both registries β€” the database table and the application code β€” in the same version control repository or with synchronized version numbers. Deploy database registry changes and application mapper changes atomically in the same release. Add automated integration tests that query the database registry and compare it against the application's in-memory registry, failing the build if any code exists in one but not the other. When the registries diverge β€” a new code in the database but not in the application β€” the application's mapper returns 'unknown error' and escalates to operations. This is intentionally conservative: an unknown code means the contract is broken, and escalation ensures it gets fixed quickly rather than silently mishandled.

Frequently Asked Questions

What is the maximum message length for RAISE_APPLICATION_ERROR?

The message parameter accepts up to 2048 bytes. However, most client tools β€” SQL*Plus, SQL Developer, JDBC's default error display β€” show roughly the first 512 bytes. The full message is available through the error stack APIs: DBMS_UTILITY.FORMAT_ERROR_STACK in PL/SQL, SQLException.getMessage() in Java, the exception args in Python. The practical rule is to front-load the most critical diagnostic information β€” error type, entity ID, invalid value, original SQLCODE β€” in the first 512 bytes.

Can I use RAISE_APPLICATION_ERROR outside of exception handlers?

Yes. RAISE_APPLICATION_ERROR can be called anywhere in a PL/SQL block β€” in regular procedural code, inside IF/THEN blocks, in triggers, in package initialization sections, and in exception handlers. When called outside an exception handler, it raises a new exception with the specified code and message. There is no existing error stack to preserve or replace, so the keep_error_stack parameter has no practical effect. This is the typical usage for input validation: check a condition, and if it fails, raise a custom error immediately.

What happens if I use an error code outside the -20000 to -20999 range?

Oracle raises ORA-21000: error number argument to raise_application_error of <your_number> is out of range. The RAISE_APPLICATION_ERROR call itself fails, and the caller sees ORA-21000 instead of your intended custom error. This is not a silent ignore β€” it is an explicit Oracle error about your error code. The fix is to use a code within the -20000 to -20999 range. Add a validation check in your centralized raise procedure to catch this before it reaches production.

How do I access the custom error code in Java (JDBC)?

Use SQLException.getErrorCode() to retrieve the custom error code as an integer. Oracle returns the code as a positive number (20001 for ORA-20001). Map this code to your application's error registry to determine the recovery action. Use SQLException.getMessage() for the full error message. Do not parse the message string for decision logic β€” use the integer error code as the contract between database and application.

Should I use RAISE_APPLICATION_ERROR for every exception in every procedure?

No. Use RAISE_APPLICATION_ERROR at API boundaries β€” the procedures that the application calls directly β€” where the caller needs a custom error code to determine the recovery action. For internal procedures that are called only by other PL/SQL code, bare RAISE (to re-raise unchanged) or implicit propagation (no handler) may be more appropriate, because the calling PL/SQL code can handle the raw Oracle error directly. The decision question is: does the caller need a custom code to decide what to do? If the caller is an application: yes, use RAISE_APPLICATION_ERROR. If the caller is another PL/SQL procedure that has its own handler: possibly not.

How do I test RAISE_APPLICATION_ERROR code paths?

Write automated tests that trigger each exception path by providing inputs that cause the specific error: invalid IDs for not-found, duplicate data for unique constraint violations, conflicting states for business rule checks. Assert on three things: the SQLCODE returned matches the expected custom error code, the message contains the expected diagnostic context (entity IDs, error type prefix), and the stack behavior matches the design (keep_error_stack=TRUE preserves the original error in the stack). Use utPLSQL or a similar PL/SQL testing framework. Include integration tests on the application side that verify the error code mapper returns the correct recovery action, user message, and retryable flag for each code.

πŸ”₯
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.

← PreviousComplete List of Common Oracle Errors That Cause ORA-06512Next β†’Using PRAGMA EXCEPTION_INIT to Map Oracle Errors
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged