RAISE_APPLICATION_ERROR — How Generic Codes Cost $2.4M
ORA-06512 with no custom code hid a $2.4M duplicate order bug for 3 weeks.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
RAISE_APPLICATION_ERROR is Oracle PL/SQL's built-in mechanism for raising user-defined exceptions with custom error numbers and messages, bypassing the default Oracle error system. It exists because standard Oracle errors (like ORA-01403 for NO_DATA_FOUND) are too generic for application logic — they don't tell you which customer record failed or why a transaction was rejected.
By calling RAISE_APPLICATION_ERROR with a negative number between -20000 and -20999, you can surface domain-specific errors (e.g., -20101 for 'Insufficient funds for account X') directly to the calling application, preserving the full error stack trace. This is critical in production systems where a generic 'constraint violation' error can cost hours of debugging, as the $2.4M incident demonstrated when a bank's batch job swallowed a -20001 'Invalid currency code' and returned a vague ORA-00001 instead.
Alternatives include user-defined exception variables (for in-PL/SQL handling only) or DBMS_OUTPUT (for debugging, not production). You should NOT use RAISE_APPLICATION_ERROR for internal control flow within a single procedure — use local exceptions or RETURN values instead.
The function takes three parameters: error_number (must be -20000 to -20999), message (up to 2048 bytes), and an optional keep_error_stack boolean (default FALSE) that controls whether the original error context is preserved when re-raising from an exception handler. This last parameter is crucial for debugging: setting it to TRUE in WHEN OTHERS blocks prevents the silent loss of the root cause error, which is exactly how that $2.4M bug manifested — a WHEN OTHERS handler re-raised a generic -20000 without the original ORA-02291 foreign key violation, causing a 3-day outage while developers traced phantom failures.
RAISE_APPLICATION_ERROR is Oracle's mechanism for raising custom errors from PL/SQL code. Instead of letting Oracle generate a generic ORA-06512 stack trace that tells the caller nothing useful — just a procedure name and a line number — you define your own error number (between -20000 and -20999) and a descriptive message that explains what happened and why. The caller sees YOUR error code and YOUR message, not a cryptic reference to line 847 of a stored procedure they cannot access. Think of it as the difference between a crash dump and an error message that says 'Order 12345 was already processed — do not retry.'
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.
Why Generic Error Codes Cost $2.4M
RAISE_APPLICATION_ERROR is Oracle's built-in mechanism for raising user-defined exceptions from PL/SQL with a custom error number (between -20000 and -20999) and a message up to 2048 bytes. It lets you abort a transaction and return a specific, meaningful error to the calling application, replacing generic ORA-00001 or ORA-02290 messages that tell developers nothing about what actually went wrong.
The procedure takes three parameters: error_number, message, and an optional keep_errors flag (default FALSE). When raised inside a trigger or stored procedure, it immediately stops execution, rolls back the current statement (not the whole transaction unless you handle it), and sends the error back to the client. The keep_errors parameter, when set TRUE, collects multiple errors in a single call — useful for batch validation but rarely used correctly.
Use RAISE_APPLICATION_ERROR when you need domain-specific validation that the database enforces — for example, rejecting an order when inventory is insufficient or blocking a salary update that exceeds policy limits. Without it, applications rely on parsing generic constraint violation messages, which is brittle, slow, and leads to production incidents like the $2.4M loss caused by a misclassified ORA-00001 that bypassed business logic checks.
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
The Exception Hierarchy: Why You Can't Fire a Missile with a CAPTCHA Error
You've been coding three years. You think exceptions are just fancy IF statements. They're not. PL/SQL has three distinct layers of exceptions, and mixing them up is why we spent last weekend rebuilding the inventory API. First: internally defined exceptions like NO_DATA_FOUND or DUP_VAL_ON_INDEX. Oracle fires these automatically. You don't get to choose. Second: user-defined exceptions declared with the EXCEPTION keyword. You control the trigger but not the error code. Third: the current exception (SQLERRM, SQLCODE) — a variable holding whatever went wrong last. Why does this matter? Because RAISE_APPLICATION_ERROR gives you a fourth path — custom error codes with custom messages. When you raise a user-defined exception in a trigger, and the application catches SQLCODE -20001 from an auto-increment conflict, you've just confused a data integrity violation with a business rule. Production calls that 'lost revenue.' Know which layer you're in before you raise anything.
// io.thecodeforge -- Bad: mixing exception types in one handler DECLARE e_insufficient_balance EXCEPTION; PRAGMA EXCEPTION_INIT(e_insufficient_balance, -20001); BEGIN -- This RAISE fires a user-defined exception, not RAISE_APPLICATION_ERROR RAISE e_insufficient_balance; EXCEPTION WHEN DUP_VAL_ON_INDEX THEN -- Silent failure for a different error type NULL; WHEN e_insufficient_balance THEN -- No error code mapping back to app DBMS_OUTPUT.PUT_LINE('Insufficient balance'); END;
Current Exception Context: The Debugging Tool You're Ignoring
You've seen this pattern: a WHEN OTHERS block with a DBMS_OUTPUT that prints 'Error occurred.' That's not debugging. That's a confession. Every exception in PL/SQL carries a current exception context — SQLCODE and SQLERRM — that holds the exact error number and message from the last raised exception. When you override it with your own RAISE without capturing the context, you lose the root cause. Here's the rule: in any exception handler, assign SQLERRM to a logging variable before you raise another exception. Why? Because RAISE_APPLICATION_ERROR with keep_error_stack=>TRUE preserves the stack, but it does NOT keep SQLERRM from the original exception if you re-raise a different one. The production reality: a batch job hits a deadlock, you catch it, log 'Deadlock detected', then raise your generic -20001. The DBAs see error code -20001 with no stack trace. That's a 3-hour incident investigation instead of a 10-minute deadlock kill. Never lose the current exception context.
// io.thecodeforge CREATE OR REPLACE PROCEDURE fail_on_purchase( p_product_id IN NUMBER ) AS v_sqlerrm VARCHAR2(4000); BEGIN UPDATE inventory SET quantity = quantity - 1 WHERE product_id = p_product_id; -- Assume deadlock here EXCEPTION WHEN OTHERS THEN -- Capture current context before it's lost v_sqlerrm := SQLERRM; -- Log to audit table with original error INSERT INTO error_log (timestamp, sqlcode, sqlerrm, context) VALUES (SYSTIMESTAMP, SQLCODE, v_sqlerrm, 'purchase_flow'); -- Now raise your custom error, preserving stack RAISE_APPLICATION_ERROR(-20102, 'Purchase failed: ' || v_sqlerrm, TRUE); END;
Generic ORA-06512 Hid a Duplicate Order for 3 Weeks — $2.4M in Double Charges
- Never use a single generic error code for all exceptions — each distinct failure mode needs a distinct error code so the application can respond correctly
- WHEN OTHERS without specific re-raising and context preservation is the root cause of the majority of un-debuggable production errors in PL/SQL systems
- Always include the original SQLCODE, SQLERRM, and relevant business context (entity IDs, values, states) in the RAISE_APPLICATION_ERROR message
- The application must map custom error codes to specific recovery actions — generic errors force blind retries that can cause data corruption
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.| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| io | CREATE OR REPLACE PROCEDURE app_schema.validate_order( | RAISE_APPLICATION_ERROR Syntax and Parameters |
| io | CREATE TABLE app_schema.error_registry ( | Error Number Strategy |
| io | CREATE OR REPLACE PROCEDURE app_schema.create_order_v1( | Stack Behavior |
| io | CREATE OR REPLACE PROCEDURE app_schema.create_order_bad( | WHEN OTHERS Anti-Pattern |
| io | /** | Application-Side Error Code Mapping |
| io | CREATE OR REPLACE PROCEDURE app_schema.find_order_raise( | RAISE_APPLICATION_ERROR vs. RAISE vs. Implicit Propagation |
| ExceptionLevels.sql | DECLARE | The Exception Hierarchy |
| PreserveContext.sql | CREATE OR REPLACE PROCEDURE fail_on_purchase( | Current Exception Context |
Key takeaways
Common mistakes to avoid
6 patternsUsing a single generic error code for all exceptions
Using WHEN OTHERS without including SQLCODE, SQLERRM, and diagnostic context
Using keep_error_stack=FALSE in nested exception handlers
Using error codes outside the -20000 to -20999 range
Displaying raw RAISE_APPLICATION_ERROR messages to end users
Not testing RAISE_APPLICATION_ERROR code paths in automated tests
Interview Questions on This Topic
What is RAISE_APPLICATION_ERROR and when would you use it?
What does the keep_error_stack parameter do, and when should you use TRUE vs. FALSE?
Why is WHEN OTHERS with a generic RAISE_APPLICATION_ERROR considered an anti-pattern, and what is the correct approach?
How would you design a custom error code system for a large multi-module application?
How do you ensure the database and application error registries stay in sync, and what happens when they diverge?
Frequently Asked Questions
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.
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.
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.
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.
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.
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.
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
That's PL/SQL. Mark it forged?
10 min read · try the examples if you haven't