How to Use RAISE_APPLICATION_ERROR Properly in Oracle
- 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
- 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 Incident
Production Debug GuideFrom generic ORA-06512 to root cause identification
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 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.
-- 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; /
- 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
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.
-- 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; /
- 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
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.
-- 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.
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.
-- 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; /
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.
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(); } }
- 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
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).
-- 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)
- 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
| Mechanism | Custom Code | Custom Message | Preserves Original Stack | Best Use Case |
|---|---|---|---|---|
| RAISE (no arguments) | No β original code propagates | No β original message propagates | Yes β stack is unchanged | Re-raise after logging or cleanup; caller can handle the raw Oracle error |
| RAISE_APPLICATION_ERROR (keep_error_stack=FALSE) | Yes β -20000 to -20999 | Yes β up to 2048 bytes | No β original stack is replaced | Top-level API boundary; deliberately hide internal implementation details from external callers |
| RAISE_APPLICATION_ERROR (keep_error_stack=TRUE) | Yes β -20000 to -20999 | Yes β up to 2048 bytes | Yes β custom error chained onto original stack | Nested handlers; add business context while preserving the original Oracle error for debugging |
| Implicit propagation (no handler) | No β original Oracle code | No β original Oracle message | Yes β stack is unchanged | Internal utility procedures; calling PL/SQL code handles the raw error directly |
| Centralized handle_exception procedure | Yes β mapped from SQLCODE | Yes β structured format with context | Yes β uses keep_error_stack=TRUE | Consistent 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
Interview Questions on This Topic
- QWhat is RAISE_APPLICATION_ERROR and when would you use it?JuniorReveal
- QWhat does the keep_error_stack parameter do, and when should you use TRUE vs. FALSE?Mid-levelReveal
- QWhy is WHEN OTHERS with a generic RAISE_APPLICATION_ERROR considered an anti-pattern, and what is the correct approach?Mid-levelReveal
- QHow would you design a custom error code system for a large multi-module application?SeniorReveal
- QHow do you ensure the database and application error registries stay in sync, and what happens when they diverge?SeniorReveal
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.
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.