ORA-20001 — One Code Masked $340K Duplicate Refunds
A generic ORA-20001 error hid five failure modes, causing $340K duplicate refunds.
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
- 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
ORA-20001 is a user-defined exception in Oracle's reserved error code range -20000 to -20999, which allows PL/SQL developers to raise application-specific errors with custom messages. Unlike system exceptions (e.g., ORA-01403 for NO_DATA_FOUND), these codes are never thrown by Oracle itself — they exist solely for your code to signal business logic failures, validation errors, or audit violations.
The range gives you 1,000 distinct error numbers to map to specific failure modes, which is critical when you need to distinguish a 'duplicate refund' from a 'credit limit exceeded' in logs, monitoring, or application-side error handlers. Without this range, developers often abuse generic ORA-20000 (the catch-all) or raise non-standard exceptions that get lost in stack traces, making it impossible to automate recovery or alerting.
In practice, ORA-20001 through ORA-20999 are raised via RAISE_APPLICATION_ERROR(-20001, 'message') inside PL/SQL blocks. The error number becomes the primary key for your error catalog — a $340K duplicate-refund bug was masked because the team used ORA-20000 for everything, so the refund logic's failure to check for prior payments looked identical to a transient network timeout.
With a dedicated code like ORA-20001 for 'duplicate payment detected', monitoring tools (Datadog, Splunk) could have triggered an immediate alert instead of burying it in a sea of generic errors. The companion error ORA-06512 is not the error itself — it's the stack trace frame that shows where the exception propagated through nested blocks, which is how you trace the exact code path that led to the failure.
You should use custom error codes when your application has distinct failure modes that require different recovery actions: retry, rollback, notify ops, or block the user. Don't use them for transient system errors (those belong to Oracle's range) or for internal debugging (use DBMS_OUTPUT or logging tables).
The -20000 range is a contract between your PL/SQL code and the application layer — map each code to an HTTP status, a user-facing message, and a retry policy. For example, ORA-20001 might map to HTTP 409 Conflict with 'Duplicate transaction rejected', while ORA-20002 maps to HTTP 422 with 'Insufficient funds'.
This pattern is used by major ERP systems (Oracle E-Business Suite, PeopleSoft) where a single WHEN OTHERS handler can parse the error code and dispatch the correct recovery logic without guessing from the message text.
Oracle reserves the error number range -20000 to -20999 for application developers. When you call RAISE_APPLICATION_ERROR(-20050, 'Insufficient balance: account=789 balance=50 required=100'), you are telling Oracle: raise error ORA-20050 with my custom message. Oracle then attaches an ORA-06512 frame to the stack showing the exact procedure name and line number where the error was raised. The custom code (-20050) tells you WHAT failed. The ORA-06512 frame tells you WHERE it failed. The message tells you WHY and with what data. Together they form a complete diagnostic that an application can act on and an engineer can debug from.
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.
Why ORA-20001 Exists — One Code Masked $340K Duplicate Refunds
User-defined errors in the ORA-20000–ORA-20999 range are custom exceptions raised by PL/SQL via RAISE_APPLICATION_ERROR. The core mechanic: you call this procedure with an error number (20000–20999) and a message; Oracle aborts the current transaction and returns both to the client. Unlike built-in exceptions, these codes are invisible to Oracle's internal error handling — they pass through unchanged, making them the only way to surface application-level validation failures as database errors.
In practice, each code maps to exactly one business rule. The message is limited to 2048 bytes, and the error number must be negative when raised (e.g., -20001). The calling code — Java via JDBC, Python via cx_Oracle — catches it as a SQLException with getErrorCode() returning the positive version. This lets you distinguish a 'duplicate refund' from a 'credit limit exceeded' without parsing strings. The key property: the error is atomic with the transaction — if you see ORA-20001, the work is rolled back.
Use this range when a business invariant violation must abort the unit of work and be programmatically distinguishable. Real systems rely on it for idempotency checks, fund movement guards, and concurrency conflict detection. Without it, you'd either parse generic ORA-00001 (unique constraint) messages — brittle and slow — or handle validation in the app layer after a commit, risking data corruption. The ORA-20000 range is the database's native way to say 'this operation is invalid, and here's exactly why.'
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
System-Defined vs User-Defined: When to Roll Your Own
Oracle gives you two exception families. System-defined exceptions like NO_DATA_FOUND and ZERO_DIVIDE cover database rule violations. They're fine for generic constraint failures. But they're useless when your business logic blows up: duplicate refunds, stale inventory claims, payment gateway timeouts. That's where your -20000 range comes in. System exceptions have fixed meanings. You can't overload NO_DATA_FOUND to mean 'credit limit exceeded' without creating a debugging nightmare. User-defined exceptions let you encode business state directly into the error number. The rule: use system exceptions for the database telling you it broke. Use user-defined exceptions for your application telling you business rules broke. Mixing them is how you spend Sunday afternoon tracing a 'ORA-01403' that turned out to be a pricing engine meltdown.
// io.thecodeforge -- Wrong: Overloading system exception DECLARE ex_credit_limit EXCEPTION; PRAGMA EXCEPTION_INIT(ex_credit_limit, -100); BEGIN RAISE ex_credit_limit; EXCEPTION WHEN ex_credit_limit THEN DBMS_OUTPUT.PUT_LINE('Overloaded -100? Bad idea'); END; / -- Right: Use reserved range for business logic DECLARE ex_credit_limit EXCEPTION; PRAGMA EXCEPTION_INIT(ex_credit_limit, -20001); BEGIN RAISE ex_credit_limit; EXCEPTION WHEN ex_credit_limit THEN DBMS_OUTPUT.PUT_LINE('Business rule violation at -20001'); END; /
Named vs Unnamed: Don't Let Your Errors Go Incognito
PL/SQL gives you two ways to define user exceptions. Named exceptions use DECLARE with EXCEPTION_INIT to bind a human-readable name to a -20000 number. Unnamed exceptions are RAISE_APPLICATION_ERROR calls with no named handler. Named exceptions are for errors you expect and plan to catch. They give your handlers a clear identifier. Unnamed exceptions are for 'this should never happen' scenarios — you want them to propagate unhandled so ops sees the full stack. The trap: teams lazy-define everything as RAISE_APPLICATION_ERROR with no named exception. Now every error is a generic ORA-20000. You lose the ability to write targeted WHEN handlers. Your error handling becomes a chain of IF SQLCODE = -20001 THEN ... ELSIF SQLCODE = -20002 THEN ... which is a maintenance nightmare. Name your recoverable errors. Leave the 'die immediately' errors unnamed.
// io.thecodeforge -- Named: Catchable, maintainable CREATE OR REPLACE PROCEDURE process_payment( p_amount NUMBER ) AS ex_insufficient_funds EXCEPTION; PRAGMA EXCEPTION_INIT(ex_insufficient_funds, -20100); BEGIN IF p_amount > 10000 THEN RAISE ex_insufficient_funds; END IF; DBMS_OUTPUT.PUT_LINE('Payment processed'); EXCEPTION WHEN ex_insufficient_funds THEN DBMS_OUTPUT.PUT_LINE('Hold for manager review'); END; / -- Unnamed: Fatal, propagates raw CREATE OR REPLACE PROCEDURE verify_checksum( p_hash VARCHAR2 ) AS BEGIN IF p_hash IS NULL THEN RAISE_APPLICATION_ERROR(-20200, 'Checksum cannot be null'); END IF; END; /
Generic -20001 Error Code Hid a $340K Double Refund Behind ORA-06512
- A single generic error code for all failures is actively worse than no custom error at all — it gives the application false confidence that it can handle the error, but it cannot distinguish failure modes
- ORA-20001 is not a 'server error' — it is whatever the developer put there — always read the message, and always ensure the code distinguishes failure types
- The application must map each custom error code to exactly one recovery action — one code per failure mode, one action per code
- Include the original SQLCODE, SQLERRM, and relevant entity IDs in the custom message — the original Oracle error is diagnostic context that the production team needs at 3 AM
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.| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| io | BEGIN | The -20000 to -20999 Range |
| io | CREATE OR REPLACE PROCEDURE app_schema.validate_balance( | ORA-06512 Mechanics |
| io | CREATE OR REPLACE PROCEDURE app_schema.create_order( | Error Propagation Through Nested PL/SQL Blocks |
| io | CREATE TABLE app_schema.error_registry ( | Designing a Custom Error Code System |
| io | /** | Application-Side Error Code Mapping and Recovery |
| io | SELECT | Debugging Custom Errors |
| exception_policy.sql | DECLARE | System-Defined vs User-Defined |
| named_vs_unnamed.sql | CREATE OR REPLACE PROCEDURE process_payment( | Named vs Unnamed |
Key takeaways
Common mistakes to avoid
6 patternsUsing a single generic error code (-20001) for every failure mode in a procedure
Treating ORA-06512 as the error instead of reading the custom error code above it
Using error codes outside the -20000 to -20999 range
Not registering error codes in the error registry before using them in code
Using keep_error_stack=FALSE in nested exception handlers
Not including entity IDs, data values, and original SQLCODE in the custom error message
Interview Questions on This Topic
What is the relationship between ORA-20001 to ORA-20999 and ORA-06512?
Why is using a single generic error code for all failures considered a critical anti-pattern?
How do custom errors propagate through nested PL/SQL blocks, and what role does keep_error_stack play?
How would you design a production-grade custom error system for a large multi-module application?
How do you read an error stack that contains multiple custom error codes interleaved with ORA-06512 frames?
Frequently Asked Questions
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.
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.
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.
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.
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.
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.
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