ORA-20001 to ORA-20999: User-Defined Errors and ORA-06512
- ORA-20001 to ORA-20999 is Oracle's exclusive range for application-defined custom errors β codes outside this range cause RAISE_APPLICATION_ERROR to raise ORA-21000 instead of your intended error
- ORA-06512 is a stack frame marker β not an error β it shows WHERE the error was raised, not WHAT the error is β always read the custom error code first
- Each re-raise with keep_error_stack=TRUE adds a frame to the stack β the stack becomes a diagnostic narrative from root cause to caller
- ORA-20001 to ORA-20999 is Oracle's reserved range for application-defined custom errors raised via RAISE_APPLICATION_ERROR
- ORA-06512 is NOT the error β it is the stack frame that shows WHERE in the source code the error was raised or re-raised
- The custom error code (ORA-20XXX) is the WHAT β always read it first, then use ORA-06512 to locate the source line
- Custom errors propagate through nested PL/SQL blocks exactly like standard Oracle errors β each re-raise adds an ORA-06512 frame to the stack
- Error codes outside -20000 to -20999 cause RAISE_APPLICATION_ERROR to raise ORA-21000 (out of range) β the custom code and message are lost
- Production insight: the majority of production debugging time is wasted because engineers read the ORA-06512 line number and ignore the custom error code above it β they are debugging the WHERE instead of the WHAT
- Biggest mistake: using a single generic error code (-20001) for every failure mode β the code becomes meaningless, and the application cannot distinguish retryable errors from permanent ones
Production Incident
Production Debug GuideFrom custom error code to root cause identification
SQLException.getMessage(), Python via the exception args, PL/SQL via DBMS_UTILITY.FORMAT_ERROR_STACK. Retrieve the full stack programmatically. If the message is still insufficient, the RAISE_APPLICATION_ERROR call needs to front-load critical diagnostic information (error type, entity ID, original SQLCODE) in the first 512 bytes.ORA-20001 through ORA-20999 is Oracle's designated range for user-defined application errors. These codes are assigned exclusively via RAISE_APPLICATION_ERROR and carry a developer-defined message of up to 2048 bytes. They appear in error stacks alongside ORA-06512 frames, which provide the schema, object name, and line number of the raising location.
The relationship between custom errors and ORA-06512 is the most frequently misunderstood concept in Oracle error handling. ORA-06512 is not an error β it is a stack frame marker. It answers the question 'where in the code did this happen?' The actual error β the thing that went wrong β is the preceding ORA-XXXXX code, which may be a standard Oracle error (ORA-00001 for unique constraint violation, ORA-01403 for no data found) or a custom error in the -20000 to -20999 range. Engineers who read only the ORA-06512 line number and ignore the custom error code above it are answering the wrong question. They know WHERE the error occurred but not WHAT the error is β and they waste hours navigating source code instead of reading the diagnostic message that was placed there specifically for them.
This article covers the mechanics of the -20000 to -20999 range, how custom errors propagate through nested PL/SQL blocks, how ORA-06512 frames accumulate at each level of the call chain, and how to design a production-grade custom error system that reduces debugging time from hours to minutes by making the error code itself the diagnostic.
The -20000 to -20999 Range: Oracle's Application Error Reservation
Oracle reserves the error number range -20000 to -20999 exclusively for application-defined errors. No Oracle system error, no internal Oracle code, and no Oracle utility uses this range β it is entirely under developer control. This reservation provides 1,000 unique codes for custom error conditions, which is sufficient for most applications when allocated systematically.
RAISE_APPLICATION_ERROR is the only mechanism that assigns codes in this range. The procedure accepts an error number (must be between -20000 and -20999 inclusive), 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 with the specified code and message.
Error codes outside the -20000 to -20999 range cause RAISE_APPLICATION_ERROR to fail. If you call RAISE_APPLICATION_ERROR(-30001, 'My error'), Oracle does not silently ignore the code β it raises ORA-21000: error number argument to raise_application_error of -30001 is out of range. The caller sees ORA-21000, not your custom error. Your intended code and message are completely lost. This is not a silent degradation β it is an explicit Oracle error about your error code β but it is equally confusing because the caller sees an error about raise_application_error rather than your business logic.
The 1,000-code range is more than sufficient for most applications when used systematically. The recommended allocation strategy is to partition the range into blocks of 100 codes per module, with consistent sub-ranges within each block for specific error categories. This prevents cross-module collisions and makes error codes self-documenting: any engineer can look at -20112 and immediately know it is a payments module (21xx) duplicate error (x1x).
-- Valid: error code in the reserved range BEGIN RAISE_APPLICATION_ERROR(-20001, 'ORDER_NOT_FOUND: order_id=12345'); END; / -- Caller sees: ORA-20001: ORDER_NOT_FOUND: order_id=12345 -- The custom code and message are preserved correctly -- INVALID: error code outside the range β raises ORA-21000 BEGIN RAISE_APPLICATION_ERROR(-30001, 'My custom error'); END; / -- Caller sees: ORA-21000: error number argument to raise_application_error -- of -30001 is out of range -- The custom code -30001 and message 'My custom error' are LOST -- INVALID: positive error number β raises ORA-21000 BEGIN RAISE_APPLICATION_ERROR(20001, 'My custom error'); END; / -- Same result: ORA-21000 β positive numbers are out of range -- Range allocation example: -- Module allocation (100 codes per module): -- -20000 to -20099: Orders module -- -20100 to -20199: Payments module -- -20200 to -20299: Inventory module -- -20300 to -20399: Shipping module -- -20400 to -20499: Customer module -- -20900 to -20999: Infrastructure / shared / retryable errors -- -- Sub-range convention within each module: -- XX01 to XX09: Not-found errors -- XX10 to XX19: Duplicate errors -- XX20 to XX29: Invalid state errors -- XX30 to XX39: Business rule violations -- XX40 to XX49: External system / integration errors -- XX50 to XX59: Retryable / transient errors -- XX99: Unexpected / catch-all error for the module -- Diagnostic: find ALL RAISE_APPLICATION_ERROR calls in the codebase SELECT owner, name, type, line, TRIM(text) AS source_line FROM dba_source WHERE UPPER(text) LIKE '%RAISE_APPLICATION_ERROR%' AND owner NOT IN ('SYS', 'SYSTEM') ORDER BY owner, name, line; -- Review each call: verify the code is in range and the message -- includes diagnostic context (entity IDs, original SQLCODE) -- Diagnostic: find error codes that may be outside the valid range -- This regex extracts the first numeric argument to RAISE_APPLICATION_ERROR SELECT owner, name, type, line, REGEXP_SUBSTR(text, 'RAISE_APPLICATION_ERROR\s*\(\s*(-?\d+)', 1, 1, 'i', 1) AS error_code, TRIM(text) AS source_line FROM dba_source WHERE UPPER(text) LIKE '%RAISE_APPLICATION_ERROR%' AND owner NOT IN ('SYS', 'SYSTEM') AND REGEXP_SUBSTR(text, 'RAISE_APPLICATION_ERROR\s*\(\s*(-?\d+)', 1, 1, 'i', 1) IS NOT NULL AND NOT REGEXP_LIKE( REGEXP_SUBSTR(text, 'RAISE_APPLICATION_ERROR\s*\(\s*(-?\d+)', 1, 1, 'i', 1), '^-20[0-9]{3}$' ) ORDER BY owner, name, line; -- Every row returned is a RAISE_APPLICATION_ERROR call with an error code -- outside -20000 to -20999 β these will raise ORA-21000 at runtime
- 1,000 unique codes available: -20000 to -20999 inclusive β sufficient for most applications when allocated systematically
- Codes outside the range raise ORA-21000 β the custom code and message are permanently lost
- Positive codes also raise ORA-21000 β only negative codes in the range work
- RAISE_APPLICATION_ERROR is the only mechanism that assigns codes in this range β there is no other way to raise custom numbered errors
- Allocate 100 codes per module with consistent sub-ranges for error types β prevents collisions and makes codes self-documenting
ORA-06512 Mechanics: The Stack Frame, Not the Error
ORA-06512 is not an error. It is a stack frame marker. It provides the schema name, the object name (procedure, function, package body, trigger), and the line number of the location where an exception was raised or re-raised. It appears in the error stack alongside the actual error β the ORA-XXXXX code that describes what went wrong.
The error stack is read from top to bottom for identification and from bottom to top for root cause analysis. The top entry is the most recent error β the one the caller's application code sees first. Below it are ORA-06512 frames showing the call chain. The bottom entry is the deepest frame β the original error location, the root cause.
For a custom error raised in a single procedure, the stack looks like: ORA-20050: INSUFFICIENT_BALANCE: account=789 balance=50 required=100 ORA-06512: at "APP_USER.PAYMENTS_PKG", line 42
The custom code tells you WHAT happened. The ORA-06512 tells you WHERE in the source it happened. Together they provide a complete diagnostic.
When an exception propagates through multiple PL/SQL levels β which is the normal case in any non-trivial application β each level that catches and re-raises the exception adds an ORA-06512 frame. A three-level call chain (procedure A calls procedure B calls procedure C) where C raises the error and B re-raises with context produces multiple frames. Reading the stack from bottom to top reveals the full call chain: where the original error occurred (C), where it was caught and re-raised with context (B), and where it propagated to (A).
The keep_error_stack parameter (the third argument to RAISE_APPLICATION_ERROR) controls whether the original error is preserved in the stack. With TRUE, the original error (ORA-00001, ORA-01403, or an inner ORA-20XXX) is preserved below the new custom error. With FALSE (the default), the original error is discarded β only the new custom error and its ORA-06512 frame remain. In nested call chains, using FALSE at any level permanently destroys all diagnostic information below that level.
-- Example: Three-level call chain with custom errors -- Level C: Raises the root cause error CREATE OR REPLACE PROCEDURE app_schema.validate_balance( p_account_id IN NUMBER, p_amount IN NUMBER ) IS v_balance NUMBER; BEGIN SELECT balance INTO v_balance FROM data_owner.accounts WHERE account_id = p_account_id; IF v_balance < p_amount THEN RAISE_APPLICATION_ERROR( -20102, 'INSUFFICIENT_BALANCE: account_id=' || p_account_id || ' balance=' || v_balance || ' required=' || p_amount ); -- This line creates: -- ORA-20102: INSUFFICIENT_BALANCE: account_id=789 balance=50 required=100 -- ORA-06512: at "APP_SCHEMA.VALIDATE_BALANCE", line 12 END IF; EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR( -20101, 'ACCOUNT_NOT_FOUND: account_id=' || p_account_id, TRUE ); END validate_balance; / -- Level B: Catches, adds context, re-raises with keep_error_stack=TRUE CREATE OR REPLACE PROCEDURE app_schema.charge_payment( p_order_id IN NUMBER ) IS v_account_id NUMBER; v_amount NUMBER; BEGIN SELECT account_id, total_amount INTO v_account_id, v_amount FROM data_owner.orders WHERE order_id = p_order_id; app_schema.validate_balance(v_account_id, v_amount); EXCEPTION WHEN OTHERS THEN -- Add order-level context, preserve the balance error in the stack RAISE_APPLICATION_ERROR( -20100, 'PAYMENT_FAILED: order_id=' || p_order_id || ' | step_error=' || SQLERRM, TRUE -- preserve the original -20102 error below ); -- This adds: -- ORA-20100: PAYMENT_FAILED: order_id=12345 | step_error=... -- ORA-06512: at "APP_SCHEMA.CHARGE_PAYMENT", line 18 END charge_payment; / -- Level A: The caller β receives the full stack CREATE OR REPLACE PROCEDURE app_schema.process_order( p_order_id IN NUMBER ) IS BEGIN app_schema.charge_payment(p_order_id); -- If charge_payment fails, the exception propagates here -- Level A has no exception handler, so the error propagates to the application END process_order; / -- The COMPLETE error stack the application sees (with keep_error_stack=TRUE): -- -- ORA-20100: PAYMENT_FAILED: order_id=12345 | step_error=ORA-20102: INSUFFICIENT_BALANCE... -- ORA-06512: at "APP_SCHEMA.CHARGE_PAYMENT", line 18 -- ORA-20102: INSUFFICIENT_BALANCE: account_id=789 balance=50 required=100 -- ORA-06512: at "APP_SCHEMA.VALIDATE_BALANCE", line 12 -- -- Reading bottom to top (root cause analysis): -- 1. Line 12 in VALIDATE_BALANCE: INSUFFICIENT_BALANCE raised (root cause) -- 2. Line 18 in CHARGE_PAYMENT: PAYMENT_FAILED wrapped it with order context -- 3. The application sees ORA-20100 at the top β the outermost error -- WITHOUT keep_error_stack=TRUE (if B used FALSE): -- ORA-20100: PAYMENT_FAILED: order_id=12345 -- ORA-06512: at "APP_SCHEMA.CHARGE_PAYMENT", line 18 -- (The ORA-20102 INSUFFICIENT_BALANCE and its location are GONE) -- (The root cause is permanently lost)
- Top of stack: the most recent error β the custom code the application caller sees first
- Below it: ORA-06512 frames showing schema, object, and line number at each call level
- Bottom of stack: the deepest frame β the original error location (root cause)
- Read bottom to top for root cause analysis β read top to bottom for what the caller should act on
- keep_error_stack=TRUE preserves the original error chain β FALSE destroys everything below the current level
Error Propagation Through Nested PL/SQL Blocks
Custom errors propagate through nested PL/SQL blocks using the same mechanics as standard Oracle errors. Understanding these mechanics is essential because production PL/SQL systems are always nested β a top-level procedure calls utility procedures which call data access procedures which execute SQL statements. Errors originate at the deepest level and propagate upward through each level's exception handler (or lack thereof).
The propagation rules are deterministic. An unhandled exception in an inner block propagates to the immediately enclosing block. If the enclosing block has a matching exception handler (WHEN specific_exception or WHEN OTHERS), that handler executes. If the handler re-raises β either with RAISE (propagates the original exception unchanged) or with RAISE_APPLICATION_ERROR (replaces or chains with a custom error) β the exception continues propagating to the next enclosing block. If the handler does not re-raise, the exception is consumed and execution continues after the inner block β the caller never knows the error occurred.
This last point is the source of a critical anti-pattern: WHEN OTHERS handlers that do not re-raise. A WHEN OTHERS handler that catches the exception, logs it (or does nothing), and returns normally has silently converted an exception into a successful return. The calling procedure continues executing as if nothing happened. If the inner procedure was supposed to insert a record and it failed, the calling procedure will continue processing as if the record exists. This leads to data inconsistencies that are discovered days or weeks later.
Each re-raise through a RAISE_APPLICATION_ERROR call with keep_error_stack=TRUE adds a new custom error code and a new ORA-06512 frame to the growing error stack. This is the correct pattern for multi-layer systems: each layer catches the exception, adds its own business context (what operation was being attempted, what entity was being processed), and re-raises with a code appropriate to its abstraction level. The result is an error stack that reads like a diagnostic narrative from root cause to caller.
FORALL with SAVE EXCEPTIONS introduces a variation: the bulk operation continues processing when individual rows fail. Each failed row generates an exception stored in SQL%BULK_EXCEPTIONS β a collection containing the index of the failed row and its Oracle error code. After the FORALL completes, the procedure can iterate over the failures, log each one, and raise a summary custom error reporting how many rows failed and referring the operator to the error log for details.
-- Propagation Pattern 1: No handler β exception propagates unchanged CREATE OR REPLACE PROCEDURE app_schema.create_order( 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); -- If unique constraint violated: ORA-00001 propagates to the caller -- No exception handler β the original error passes through unchanged -- The caller sees ORA-00001 + ORA-06512 at this procedure END create_order; / -- Propagation Pattern 2: Handler adds context and re-raises with custom code CREATE OR REPLACE PROCEDURE app_schema.create_order_v2( 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 RAISE_APPLICATION_ERROR( -20010, 'DUPLICATE_ORDER: customer_id=' || p_customer_id || ' β an active order already exists | original=' || SQLERRM, TRUE -- preserve the ORA-00001 in the stack ); WHEN OTHERS THEN RAISE_APPLICATION_ERROR( -20099, 'UNEXPECTED_ERROR: module=ORDERS op=CREATE' || ' customer_id=' || p_customer_id || ' SQLCODE=' || SQLCODE || ' | original=' || SQLERRM, TRUE ); END create_order_v2; / -- Propagation Pattern 3: Nested blocks β inner exception propagates to outer CREATE OR REPLACE PROCEDURE app_schema.process_order( p_order_id IN NUMBER ) IS BEGIN -- Step 1: Validate BEGIN app_schema.validate_order(p_order_id); -- If validate_order raises -20001 (ORDER_NOT_FOUND), -- it propagates to the outer handler below END; -- Step 2: Charge BEGIN app_schema.charge_payment(p_order_id); -- If charge_payment raises -20100 (PAYMENT_FAILED), -- it propagates to the outer handler below END; EXCEPTION WHEN OTHERS THEN -- Outer handler: adds process-level context app_schema.logger_pkg.error( 'process_order', 'Failed: order_id=' || p_order_id || ' SQLCODE=' || SQLCODE, DBMS_UTILITY.FORMAT_ERROR_STACK || CHR(10) || DBMS_UTILITY.FORMAT_ERROR_BACKTRACE ); RAISE_APPLICATION_ERROR( -20030, 'ORDER_PROCESSING_FAILED: order_id=' || p_order_id || ' | step_error=' || SQLERRM, TRUE ); END process_order; / -- Propagation Pattern 4: FORALL with SAVE EXCEPTIONS CREATE OR REPLACE PROCEDURE app_schema.process_batch IS TYPE t_ids IS TABLE OF NUMBER; v_ids t_ids; v_error_count PLS_INTEGER := 0; e_bulk_errors EXCEPTION; PRAGMA EXCEPTION_INIT(e_bulk_errors, -24381); BEGIN SELECT order_id BULK COLLECT INTO v_ids FROM data_owner.staging_orders WHERE processed_flag = 'N'; BEGIN FORALL i IN 1 .. v_ids.COUNT SAVE EXCEPTIONS INSERT INTO data_owner.orders (order_id) VALUES (v_ids(i)); EXCEPTION WHEN e_bulk_errors THEN v_error_count := SQL%BULK_EXCEPTIONS.COUNT; -- Log each individual row failure FOR j IN 1 .. v_error_count LOOP app_schema.logger_pkg.error( 'process_batch', 'Row index=' || SQL%BULK_EXCEPTIONS(j).ERROR_INDEX || ' order_id=' || v_ids(SQL%BULK_EXCEPTIONS(j).ERROR_INDEX) || ' ORA-' || LPAD(SQL%BULK_EXCEPTIONS(j).ERROR_CODE, 5, '0') ); END LOOP; -- Raise summary error RAISE_APPLICATION_ERROR( -20098, 'BATCH_PARTIAL_FAILURE: ' || v_error_count || ' of ' || v_ids.COUNT || ' rows failed β see error log for details' ); END; COMMIT; END process_batch; / -- ANTI-PATTERN: WHEN OTHERS that does NOT re-raise β silently swallows errors CREATE OR REPLACE PROCEDURE app_schema.create_order_silent( 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: catches the error, does nothing, returns normally -- The caller thinks the order was created successfully -- But the INSERT failed β data is now inconsistent NULL; END create_order_silent; / -- This procedure returns SUCCESS even when the INSERT fails -- The caller has no way to know the order was not created
Designing a Custom Error Code System
A custom error code system must be systematic, documented, and enforced across the entire stack. Random code assignment β starting at -20001 and incrementing β causes collisions within months, gaps in coverage, and code that is unmaintainable after the original developer leaves. The system must serve three distinct audiences simultaneously: the database layer (raises the error with context), the application layer (maps the code to a recovery action), and the operations team (reads the error in logs and follows the recovery hint to resolve the incident).
The architecture has three layers that must stay in sync. The error registry is a database table that maps each code to its name, description, severity, retryable flag, and recovery hint. This is the single source of truth. The centralized exception handler is a PL/SQL procedure that looks up the registry, builds a structured message with diagnostic context, and calls RAISE_APPLICATION_ERROR. This eliminates per-procedure exception handling boilerplate and enforces consistent message formatting. The application mapper is a Java, Python, or .NET class that mirrors the database registry and maps each custom code to the appropriate recovery action β retry with backoff, display a user-facing message, redirect to a different flow, or escalate to operations.
The error message format must be parseable by both humans and automated tools. The proven format is: ERROR_NAME: description | context=key1=value1 key2=value2 | original=SQLERRM. This format is searchable in log aggregation systems (Splunk, ELK, Datadog), parseable by automated alerting rules, and readable by an on-call engineer at 3 AM. Front-load the error name and entity ID in the first 512 bytes β most tools truncate after that.
The error code lifecycle must be managed. New codes must be registered in the error registry before they appear in production code. A pre-deployment check scans dba_source for RAISE_APPLICATION_ERROR calls and verifies that every code exists in the registry β unregistered codes block the deployment. Deprecated codes are marked as DEPRECATED in the registry but never reused β reusing a code would cause old application versions to misinterpret the error. The registry, the PL/SQL handler, and the application mapper must be versioned and deployed atomically.
-- Layer 1: Error registry table β single source of truth CREATE TABLE app_schema.error_registry ( error_code NUMBER(6) PRIMARY KEY CONSTRAINT chk_error_range CHECK (error_code BETWEEN -20999 AND -20000), error_name VARCHAR2(60) NOT NULL UNIQUE, description VARCHAR2(500) NOT NULL, module VARCHAR2(30) NOT NULL, severity VARCHAR2(10) NOT NULL CONSTRAINT chk_severity CHECK (severity IN ('LOW','MEDIUM','HIGH','CRITICAL')), retryable VARCHAR2(1) NOT NULL CONSTRAINT chk_retryable CHECK (retryable IN ('Y','N')), recovery_hint VARCHAR2(200) NOT NULL, status VARCHAR2(10) DEFAULT 'ACTIVE' NOT NULL CONSTRAINT chk_status CHECK (status IN ('ACTIVE','DEPRECATED')), created_date DATE DEFAULT SYSDATE NOT NULL, created_by VARCHAR2(60) DEFAULT USER NOT NULL ); -- Seed: Orders module (-20000 to -20099) INSERT INTO app_schema.error_registry VALUES ( -20001, 'ORDER_NOT_FOUND', 'The specified order does not exist in the database', 'ORDERS', 'MEDIUM', 'N', 'Verify the order_id and re-submit with a valid ID', 'ACTIVE', SYSDATE, USER ); INSERT INTO app_schema.error_registry VALUES ( -20010, 'DUPLICATE_ORDER', 'An order with this ID or idempotency key already exists', 'ORDERS', 'HIGH', 'N', 'Check order history β do NOT retry', 'ACTIVE', SYSDATE, USER ); INSERT INTO app_schema.error_registry VALUES ( -20020, 'ORDER_INVALID_STATE', 'The order is in a state that does not allow this operation', 'ORDERS', 'MEDIUM', 'N', 'Check order status before attempting this operation', 'ACTIVE', SYSDATE, USER ); INSERT INTO app_schema.error_registry VALUES ( -20099, 'ORDER_UNEXPECTED', 'An unexpected error occurred in the orders module', 'ORDERS', 'CRITICAL', 'N', 'Escalate to operations with full error stack', 'ACTIVE', SYSDATE, USER ); -- Seed: Payments module (-20100 to -20199) INSERT INTO app_schema.error_registry VALUES ( -20100, 'PAYMENT_FAILED', 'Payment processing failed β see nested error for details', 'PAYMENTS', 'HIGH', 'N', 'Check the nested error code for the specific payment failure', 'ACTIVE', SYSDATE, USER ); INSERT INTO app_schema.error_registry VALUES ( -20101, 'ACCOUNT_NOT_FOUND', 'The payment account does not exist', 'PAYMENTS', 'MEDIUM', 'N', 'Verify account_id and re-submit', 'ACTIVE', SYSDATE, USER ); INSERT INTO app_schema.error_registry VALUES ( -20102, 'INSUFFICIENT_BALANCE', 'Account balance is insufficient for this transaction', 'PAYMENTS', 'MEDIUM', 'N', 'Prompt user for a different payment method', 'ACTIVE', SYSDATE, USER ); -- Seed: Infrastructure / retryable (-20900 to -20999) INSERT INTO app_schema.error_registry VALUES ( -20950, 'DEADLOCK_DETECTED', 'A deadlock was detected β the operation can be safely retried', 'INFRASTRUCTURE', 'LOW', 'Y', 'Retry with exponential backoff (max 3 retries)', 'ACTIVE', SYSDATE, USER ); INSERT INTO app_schema.error_registry VALUES ( -20951, 'OPERATION_TIMEOUT', 'The operation timed out β may be retryable', 'INFRASTRUCTURE', 'MEDIUM', 'Y', 'Retry once β if it fails again, escalate', 'ACTIVE', SYSDATE, USER ); INSERT INTO app_schema.error_registry VALUES ( -20999, 'UNEXPECTED_ERROR', 'An unexpected error occurred that does not match any known failure mode', 'INFRASTRUCTURE', 'CRITICAL', 'N', 'Escalate to operations with full error stack', 'ACTIVE', SYSDATE, USER ); COMMIT; -- Layer 2: Centralized raise procedure β enforces consistent 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 AND status = 'ACTIVE'; EXCEPTION WHEN NO_DATA_FOUND THEN v_rec.error_name := 'UNREGISTERED_ERROR'; v_rec.description := 'Error code ' || p_error_code || ' is not registered in the error registry'; END; 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; / -- Layer 3: SQLCODE-to-custom-code mapper (used in WHEN OTHERS handlers) 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_custom NUMBER; BEGIN v_custom := 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 -> invalid state WHEN -60 THEN -20950 -- deadlock -> retryable WHEN -30006 THEN -20951 -- resource busy timeout -> retryable ELSE -20999 -- everything else -> unexpected END; -- Log before raising app_schema.logger_pkg.error( p_module || '.' || p_operation, 'SQLCODE=' || v_sqlcode || ' | ' || v_sqlerrm || CASE WHEN p_context IS NOT NULL THEN ' | ' || p_context END, DBMS_UTILITY.FORMAT_ERROR_STACK || CHR(10) || DBMS_UTILITY.FORMAT_ERROR_BACKTRACE ); app_schema.raise_app_error( p_error_code => v_custom, p_context => 'module=' || p_module || ' operation=' || p_operation || ' SQLCODE=' || v_sqlcode || CASE WHEN p_context IS NOT NULL THEN ' | ' || p_context END ); END handle_exception; / -- Usage: one-line exception handler in application procedures CREATE OR REPLACE PROCEDURE app_schema.create_order( 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; / -- Pre-deployment check: find unregistered error codes in the codebase SELECT s.owner, s.name, s.line, REGEXP_SUBSTR(s.text, 'RAISE_APPLICATION_ERROR\s*\(\s*(-?\d+)', 1, 1, 'i', 1) AS error_code FROM dba_source s WHERE UPPER(s.text) LIKE '%RAISE_APPLICATION_ERROR%' AND s.owner NOT IN ('SYS', 'SYSTEM') AND REGEXP_SUBSTR(s.text, 'RAISE_APPLICATION_ERROR\s*\(\s*(-?\d+)', 1, 1, 'i', 1) IS NOT NULL AND TO_NUMBER(REGEXP_SUBSTR(s.text, 'RAISE_APPLICATION_ERROR\s*\(\s*(-?\d+)', 1, 1, 'i', 1)) NOT IN (SELECT error_code FROM app_schema.error_registry WHERE status = 'ACTIVE') ORDER BY s.owner, s.name, s.line; -- Every row returned is an unregistered error code β block deployment until registered
- Error registry table: maps each code to name, description, severity, retryable flag, and recovery hint β single source of truth
- Centralized raise procedure: looks up the registry, builds a structured message with context, calls RAISE_APPLICATION_ERROR β one place to change format
- SQLCODE mapper: translates standard Oracle errors to custom codes in WHEN OTHERS handlers β eliminates per-procedure mapping boilerplate
- Application mapper: mirrors the database registry β maps each code to user message, recovery action, and retry behavior
- Pre-deployment check: compares RAISE_APPLICATION_ERROR codes in source against the registry β blocks deployment for unregistered codes
Application-Side Error Code Mapping and Recovery
The application receives the custom error code through the database driver's error interface and must map it to the correct recovery action. This mapping is the second half of the database-application contract β the database defines what each code means, the application defines what to do when each code is received.
In Java (JDBC), the custom error code is accessible via SQLException.getErrorCode(). Oracle returns the code as a positive integer β 20001 for ORA-20001. In Python (python-oracledb, cx_Oracle), the code is available via the exception's code attribute. In .NET (ODP.NET), it is OracleException.Number. Every language provides a reliable, structured way to extract the integer code β use it. Never parse the error message string for decision logic. The message is for human diagnostics; the code is the machine-readable contract.
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 β that contains internal schema details), a severity level (for monitoring and alerting thresholds), a retryable flag (boolean β is this error safe to retry?), a maximum retry count (for retryable errors), and a recovery action (display error, redirect, retry with backoff, escalate to operations).
For retryable errors (deadlock, lock timeout, external gateway timeout), the application should implement exponential backoff with jitter. The retry count and base delay are configurable per error code via the error registry. For permanent errors (duplicate order, insufficient balance, invalid account), the application displays the appropriate user-facing message and does not retry β retrying a permanent error is how the $340K double refund incident happened. For unknown error codes β codes not in the application registry β the application should default to escalation: alert the operations team, display a generic 'contact support' message to the user, and log the full error stack for investigation. An unknown code means the contract is broken, and escalation ensures it gets fixed quickly.
package io.thecodeforge.errors; 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 app_schema.error_registry table. * * This class is the application side of the database-application error * contract. Changes to the database registry must be reflected here. * Deploy atomically. */ public final class ErrorCodeHandler { public enum Severity { LOW, MEDIUM, HIGH, CRITICAL } public enum RecoveryAction { RETRY, DISPLAY_ERROR, REDIRECT, ESCALATE } public record ErrorDefinition( String name, String userMessage, Severity severity, RecoveryAction action, boolean retryable, int maxRetries ) {} private static final Map<Integer, ErrorDefinition> REGISTRY = new ConcurrentHashMap<>(); static { // Orders 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, RecoveryAction.DISPLAY_ERROR, false, 0 )); REGISTRY.put(20010, new ErrorDefinition( "DUPLICATE_ORDER", "This order has already been submitted. Please check your order history.", Severity.HIGH, RecoveryAction.DISPLAY_ERROR, false, 0 )); REGISTRY.put(20020, new ErrorDefinition( "ORDER_INVALID_STATE", "This order cannot be modified in its current state.", Severity.MEDIUM, RecoveryAction.DISPLAY_ERROR, false, 0 )); // Payments module: -20100 to -20199 REGISTRY.put(20101, new ErrorDefinition( "ACCOUNT_NOT_FOUND", "The payment account was not found. Please verify your account details.", Severity.MEDIUM, RecoveryAction.DISPLAY_ERROR, false, 0 )); REGISTRY.put(20102, new ErrorDefinition( "INSUFFICIENT_BALANCE", "Insufficient balance. Please use a different payment method.", Severity.MEDIUM, RecoveryAction.REDIRECT, false, 0 )); // Infrastructure / retryable: -20900 to -20999 REGISTRY.put(20950, new ErrorDefinition( "DEADLOCK_DETECTED", "A temporary conflict occurred. Retrying automatically.", Severity.LOW, RecoveryAction.RETRY, true, 3 )); REGISTRY.put(20951, new ErrorDefinition( "OPERATION_TIMEOUT", "The operation timed out. Retrying automatically.", Severity.MEDIUM, RecoveryAction.RETRY, true, 2 )); REGISTRY.put(20999, new ErrorDefinition( "UNEXPECTED_ERROR", "An unexpected error occurred. Our team has been notified.", Severity.CRITICAL, RecoveryAction.ESCALATE, false, 0 )); } /** * Maps a SQLException to the appropriate ErrorDefinition. * Oracle reports error codes as positive integers (20001 for ORA-20001). * Returns a default ESCALATE definition for unregistered codes. */ public static ErrorDefinition resolve(SQLException ex) { int code = ex.getErrorCode(); ErrorDefinition def = REGISTRY.get(code); if (def == null) { // Unregistered code β contract is broken β escalate immediately return new ErrorDefinition( "UNKNOWN_ERROR", "An unexpected error occurred. Our team has been notified.", Severity.CRITICAL, RecoveryAction.ESCALATE, false, 0 ); } return def; } public static boolean isRetryable(SQLException ex) { return resolve(ex).retryable(); } public static int maxRetries(SQLException ex) { return resolve(ex).maxRetries(); } public static RecoveryAction recoveryAction(SQLException ex) { return resolve(ex).action(); } public static String userMessage(SQLException ex) { return resolve(ex).userMessage(); } }
- Database defines codes in -20000 to -20999 β each maps to exactly one failure mode via the error registry
- Application mirrors the registry β each code maps to user message, severity, retryable flag, and recovery action
- Retryable errors (deadlock, timeout) get exponential backoff with a maximum retry count
- Permanent errors (duplicate, invalid state) display the user message and do NOT retry
- Unknown codes (not in the application registry) default to ESCALATE β an unknown code means the contract is broken
- The integer error code is the contract β never parse the message string for decision logic
Debugging Custom Errors: Reading the Full Stack
The full error stack contains all the information needed to diagnose a custom error β the error type, the diagnostic context, and the exact source location. The challenge is not information availability β it is reading the stack correctly. Engineers trained on standard Oracle errors habitually read only the last line (the ORA-06512 frame) and navigate to the source code at that line number. For custom errors, this approach skips the most valuable information: the custom error code and message that were placed at the top of the stack specifically to make debugging fast.
The correct reading order is: first, identify the custom error code (ORA-20XXX) at the top of the stack. This tells you WHAT failed β the error category, the failure mode, the module it belongs to. Second, read the custom message. It contains the diagnostic context: entity IDs, data values, original SQLCODE, the operation that was being attempted. Third, if the message provides sufficient context to diagnose the issue, you are done β no source code navigation needed. Only if the message is insufficient should you use the ORA-06512 frames to navigate to the source code.
For incident response, the error stack should be pasted into the incident ticket verbatim. The on-call engineer reads the custom error code, looks it up in the error registry, and follows the recovery hint. If the recovery hint resolves the issue, the incident is closed. If it does not, the ORA-06512 line numbers provide the exact source code locations for deeper investigation.
For automated log analysis and alerting, parse the stack programmatically. Extract the custom error code with a regex pattern like ORA-(20\d{3}). Feed the code to the application's error registry to determine severity and recovery action. Alert routing can be automated: CRITICAL severity pages the on-call engineer, HIGH creates a ticket, MEDIUM logs for review, LOW is informational. The message text provides the fields for log correlation: entity IDs link the error to the transaction, SQLCODE links it to the database layer issue.
-- Example error stack (as seen by the application caller): -- -- ORA-20100: PAYMENT_FAILED: Payment processing failed β see nested error -- | order_id=12345 | step_error=ORA-20102: INSUFFICIENT_BALANCE... -- ORA-06512: at "APP_SCHEMA.CHARGE_PAYMENT", line 18 -- ORA-20102: INSUFFICIENT_BALANCE: Account balance is insufficient -- | account_id=789 balance=50 required=100 -- ORA-06512: at "APP_SCHEMA.VALIDATE_BALANCE", line 12 -- -- Step-by-step reading (bottom to top for root cause analysis): -- -- 1. Bottom frame: ORA-06512 at VALIDATE_BALANCE line 12 -- -> This is WHERE the root cause error was raised -- -- 2. Above it: ORA-20102: INSUFFICIENT_BALANCE -- -> This is WHAT the root cause error is -- -> Message contains: account_id=789, balance=50, required=100 -- -> Diagnosis: account 789 has balance 50, but 100 was needed -- -- 3. Next frame: ORA-06512 at CHARGE_PAYMENT line 18 -- -> This is WHERE the error was caught and re-raised with order context -- -- 4. Top: ORA-20100: PAYMENT_FAILED -- -> This is WHAT the caller sees β the order-level error -- -> Message contains: order_id=12345 -- -> The application maps -20100 to the PAYMENT_FAILED recovery action -- Diagnostic: Find the source code at an ORA-06512 line number -- Given: ORA-06512: at "APP_SCHEMA.VALIDATE_BALANCE", line 12 SELECT line, TRIM(text) AS source_line FROM dba_source WHERE owner = 'APP_SCHEMA' AND name = 'VALIDATE_BALANCE' AND type = 'PROCEDURE' AND line BETWEEN 8 AND 16 -- show context around line 12 ORDER BY line; -- Returns the source code around line 12 -- The RAISE_APPLICATION_ERROR call at line 12 is the exact raise point -- Diagnostic: Find ALL RAISE_APPLICATION_ERROR calls in a package or procedure SELECT line, TRIM(text) AS source_line FROM dba_source WHERE owner = 'APP_SCHEMA' AND name = 'CHARGE_PAYMENT' AND UPPER(text) LIKE '%RAISE_APPLICATION_ERROR%' ORDER BY line; -- Each result is a potential raise point -- Match the ORA-06512 line number to find which call raised the error -- Diagnostic: Look up the error code in the registry SELECT error_code, error_name, description, module, severity, retryable, recovery_hint FROM app_schema.error_registry WHERE error_code = -20102; -- Returns: -- -20102, INSUFFICIENT_BALANCE, Account balance is insufficient..., -- PAYMENTS, MEDIUM, N, Prompt user for a different payment method -- This tells the on-call engineer exactly what the error means -- and what recovery action to take -- Diagnostic: Capture and store the full error stack in PL/SQL -- Use DBMS_UTILITY functions to capture the complete diagnostic context DECLARE v_error_stack VARCHAR2(4000); v_error_bt VARCHAR2(4000); v_call_stack VARCHAR2(4000); BEGIN -- ... code that may raise an exception ... EXCEPTION WHEN OTHERS THEN v_error_stack := DBMS_UTILITY.FORMAT_ERROR_STACK; -- ORA-XXXXX + message v_error_bt := DBMS_UTILITY.FORMAT_ERROR_BACKTRACE; -- ORA-06512 frames v_call_stack := DBMS_UTILITY.FORMAT_CALL_STACK; -- Current call chain -- Log all three for maximum diagnostic value app_schema.logger_pkg.error( 'my_procedure', 'Error stack: ' || v_error_stack, v_error_bt || CHR(10) || v_call_stack ); RAISE; END; /
- Read bottom to top for root cause analysis β the deepest ORA-20XXX frame is the original failure
- The custom error code (ORA-20XXX) is the WHAT β always read it first and look it up in the registry
- The ORA-06512 frame is the WHERE β use it to find the source line only if the message is insufficient
- The message between the code and the ORA-06512 frame contains diagnostic context: entity IDs, values, original SQLCODE
- For automated parsing: extract ORA-20XXX with regex, feed to registry for severity and action, alert based on severity level
- For incident response: paste the full stack into the ticket, look up the code in the registry, follow the recovery hint
| Property | Standard Oracle Error (e.g., ORA-00001) | Custom Error (ORA-20XXX) |
|---|---|---|
| Error code range | ORA-00001 to ORA-65535 (assigned by Oracle Corporation) | ORA-20001 to ORA-20999 (assigned by the application developer) |
| Message content | Fixed Oracle message β cannot be customized by the developer | Developer-defined message β up to 2048 bytes with entity IDs and context |
| Who assigns the code | Oracle Corporation β reserved per version and feature | Developer via RAISE_APPLICATION_ERROR β each code maps to one failure mode |
| ORA-06512 behavior | Attached automatically when the error propagates through PL/SQL | Same propagation mechanics β ORA-06512 shows the RAISE_APPLICATION_ERROR line |
| Stack behavior control | Implicit β no developer control over stack preservation | Explicit β keep_error_stack=TRUE preserves the original error, FALSE replaces it |
| Application mapping | Driver maps to standard exception classes (e.g., SQLIntegrityConstraintViolationException) | Application must map code to recovery action via an error registry β no automatic mapping |
| Debugging approach | Look up the ORA code in Oracle documentation for the standard meaning | Look up the code in the application's error registry for the custom meaning and recovery hint |
| Retryable determination | Known per code by convention (e.g., ORA-00060 deadlock is retryable) | Defined explicitly by the developer in the error registry's retryable flag |
π― Key Takeaways
- ORA-20001 to ORA-20999 is Oracle's exclusive range for application-defined custom errors β codes outside this range cause RAISE_APPLICATION_ERROR to raise ORA-21000 instead of your intended error
- ORA-06512 is a stack frame marker β not an error β it shows WHERE the error was raised, not WHAT the error is β always read the custom error code first
- Each re-raise with keep_error_stack=TRUE adds a frame to the stack β the stack becomes a diagnostic narrative from root cause to caller
- A single generic error code for all failures is actively harmful β it destroys the application's ability to choose the correct recovery action and can cause data corruption through blind retries
- The error code is the contract between database and application β version it, register it in the error registry, and deploy database and application changes atomically
- Always include entity IDs, data values, and original SQLCODE in the custom message β the message is the diagnostic context that makes the error actionable without source code navigation
β Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the relationship between ORA-20001 to ORA-20999 and ORA-06512?JuniorReveal
- QWhy is using a single generic error code for all failures considered a critical anti-pattern?Mid-levelReveal
- QHow do custom errors propagate through nested PL/SQL blocks, and what role does keep_error_stack play?Mid-levelReveal
- QHow would you design a production-grade custom error system for a large multi-module application?SeniorReveal
- QHow do you read an error stack that contains multiple custom error codes interleaved with ORA-06512 frames?SeniorReveal
Frequently Asked Questions
What happens if I use an error code outside the -20000 to -20999 range?
RAISE_APPLICATION_ERROR raises ORA-21000: error number argument to raise_application_error of <your_number> is out of range. Your intended custom code and message are permanently lost β the caller sees ORA-21000 instead of your business logic error. This applies to codes that are too negative (e.g., -30001), too close to zero (e.g., -19999), and positive codes (e.g., 20001). Always validate that your error code is between -20000 and -20999 inclusive.
How do I find the source code at an ORA-06512 line number?
Query dba_source with the schema, object name, and line number from the ORA-06512 frame: SELECT line, text FROM dba_source WHERE owner = 'APP_SCHEMA' AND name = 'PROCEDURE_NAME' AND line BETWEEN <target_line - 5> AND <target_line + 5> ORDER BY line. This returns the source code around the target line. If the line contains a RAISE_APPLICATION_ERROR call, that is the exact raise point. If it contains a SQL statement or a procedure call, the exception originated from that statement or call and propagated to this level.
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 chain onto, so the keep_error_stack parameter has no practical effect in this context. This is the standard usage for input validation and business rule checks: evaluate a condition, and if it fails, raise a descriptive custom error immediately.
How do I access the custom error code in my application?
In Java (JDBC): SQLException.getErrorCode() returns the error number as a positive integer (20001 for ORA-20001). In Python (python-oracledb): the exception object's code attribute. In .NET (ODP.NET): OracleException.Number. Map the code to your application's error registry to determine the recovery action. Use the integer code for programmatic decisions β never parse the error message string for decision logic.
Should every exception have a custom error code?
Every exception that the caller needs to handle differently should have a distinct custom code. Internal exceptions that are caught and fully resolved within the same procedure β where the caller never sees the error β do not need custom codes. The key question is: does the caller need to distinguish this error from other errors to choose the correct recovery action? If the caller is an application that needs to decide between retry, display, redirect, and escalate: yes, use a custom code. If the caller is another PL/SQL procedure that catches and handles the error internally: a bare RAISE or no custom code may be sufficient.
How do I prevent error code collisions across teams in a large organization?
Partition the -20000 to -20999 range into blocks assigned to specific modules or teams: -20000 to -20099 for orders, -20100 to -20199 for payments, etc. Document the allocation in the error registry table. Each team can only use codes within their assigned block. The pre-deployment check that scans dba_source against the registry catches any team using a code outside their block or a code that collides with another team's code. The registry table's UNIQUE constraint on error_name and PRIMARY KEY on error_code enforce uniqueness at the database level.
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.