Skip to content
Homeβ€Ί Databaseβ€Ί ORA-20001 to ORA-20999: User-Defined Errors and ORA-06512

ORA-20001 to ORA-20999: User-Defined Errors and ORA-06512

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: PL/SQL β†’ Topic 19 of 27
How custom errors interact with the generic ORA-06512 line number error.
βš™οΈ Intermediate β€” basic Database knowledge assumed
In this tutorial, you'll learn
How custom errors interact with the generic ORA-06512 line number error.
  • 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
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • 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 IncidentGeneric -20001 Error Code Hid a $340K Double Refund Behind ORA-06512Every exception in the refund procedure used -20001 with the message 'Refund failed'. The application retried all -20001 errors. A duplicate refund triggered -20001, the application retried, and the customer received two refunds β€” 340,000 dollars over three weeks.
SymptomCustomer support reported duplicate refunds appearing across multiple accounts. The application logs showed ORA-20001: Refund failed followed by ORA-06512: at "APP_USER.REFUND_PKG", line 127. The application team could not distinguish between a transient infrastructure error (network timeout β€” safe to retry) and a business rule violation (duplicate refund β€” must not retry) because every failure from the refund procedure produced the exact same error code and message.
AssumptionThe team assumed ORA-20001 was a generic server error that indicated a transient failure. They configured exponential backoff with 3 automatic retries for all ORA-20001 errors. No one examined the underlying Oracle exception because the custom error code was the same for every failure type β€” there was no way to tell from the code alone what had actually gone wrong.
Root causeThe refund procedure had a single WHEN OTHERS exception handler that caught all exceptions and re-raised with RAISE_APPLICATION_ERROR(-20001, 'Refund failed'). This handler mapped five completely distinct failure modes to the same error code and the same uninformative message: 1. Duplicate refund (ORA-00001 on the refund_transactions unique constraint) β†’ -20001 2. Insufficient merchant balance β†’ -20001 3. Invalid account (account not found) β†’ -20001 4. Expired card on file β†’ -20001 5. External payment gateway timeout β†’ -20001 The application's retry logic treated all five as retryable. When a customer submitted a duplicate refund request, the procedure raised ORA-00001 (unique constraint), the WHEN OTHERS handler caught it and re-raised as ORA-20001, and the application retried. On retry, the procedure took a different code path that avoided the unique constraint, and the second refund was processed. The customer received two refunds for a single transaction. This pattern repeated across thousands of transactions over three weeks, accumulating $340,000 in duplicate refunds before finance reconciliation flagged the anomaly.
FixReplaced the single WHEN OTHERS handler with specific exception handlers for each failure mode, each using a distinct error code: - DUP_VAL_ON_INDEX β†’ -20201: DUPLICATE_REFUND (not retryable) - Custom balance check β†’ -20202: INSUFFICIENT_MERCHANT_BALANCE (not retryable) - NO_DATA_FOUND on account lookup β†’ -20203: ACCOUNT_NOT_FOUND (not retryable) - Custom card expiry check β†’ -20204: CARD_EXPIRED (not retryable) - External gateway exception β†’ -20205: GATEWAY_TIMEOUT (retryable with backoff) The application error mapper was updated to map each code to the correct recovery action: -20201 through -20204 return a specific user-facing error and do not retry. -20205 retries with exponential backoff up to 3 times. Added the original SQLCODE, SQLERRM, and entity IDs (refund_id, customer_id, account_id) to every custom error message.
Key Lesson
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 modesORA-20001 is not a 'server error' β€” it is whatever the developer put there β€” always read the message, and always ensure the code distinguishes failure typesThe application must map each custom error code to exactly one recovery action β€” one code per failure mode, one action per codeInclude 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
Production Debug GuideFrom custom error code to root cause identification
Error stack shows ORA-20XXX with a custom message followed by one or more ORA-06512 frames→Read the ORA-20XXX code first — this is the custom error type. Read the message for diagnostic context (entity IDs, values, original SQLCODE). Look up the code in your error registry for the severity, retryable flag, and recovery hint. Use the ORA-06512 line number to locate the source code only if the message does not provide sufficient context.
Error stack shows ORA-06512 with NO preceding ORA-20XXX — only standard Oracle errors and line numbers→No custom error was raised. The exception was a standard Oracle error (ORA-00001, ORA-01403, etc.) that propagated without being caught by a RAISE_APPLICATION_ERROR handler. The procedure either has no exception handler, or the handler uses bare RAISE instead of RAISE_APPLICATION_ERROR. The ORA-06512 line number points to the procedure that did not handle the exception. Fix by adding a WHEN OTHERS handler with RAISE_APPLICATION_ERROR and context.
Multiple ORA-20XXX codes interleaved with ORA-06512 frames — nested custom errors→Each layer in the PL/SQL call chain added its own RAISE_APPLICATION_ERROR with keep_error_stack=TRUE. Read the stack from bottom to top: the deepest ORA-20XXX is the root cause error. Each higher ORA-20XXX adds business context about what the calling layer was doing. The topmost ORA-20XXX is what the application caller sees. If keep_error_stack=FALSE was used at any level, the errors below that level are permanently lost.
Custom error message appears truncated — critical details (entity IDs, original SQLCODE) are missing→Most client tools display roughly the first 512 bytes of the RAISE_APPLICATION_ERROR message. The full 2048-byte message is available through the error stack APIs: Java via 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.
RAISE_APPLICATION_ERROR exists in the source code but the caller sees ORA-21000 instead of the custom error→The error number is outside the -20000 to -20999 range. RAISE_APPLICATION_ERROR raises ORA-21000 (error number argument to raise_application_error is out of range) instead of your intended custom error. Verify the error number is within -20000 to -20999 inclusive. Also check whether the error number is accidentally positive (20001 instead of -20001).
Application receives ORA-06512 with no custom error code — but the procedure source contains RAISE_APPLICATION_ERROR calls→An outer exception handler is catching the custom error and re-raising with bare RAISE or with a different mechanism that discards the custom code. Trace the exception handling chain from the innermost procedure outward. Look for WHEN OTHERS handlers that use RAISE without RAISE_APPLICATION_ERROR — these propagate the exception but lose the custom code context.

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).

io/thecodeforge/errors/range_mechanics.sql Β· SQL
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
-- 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
Mental Model
Error Range Rules
The -20000 to -20999 range is Oracle's reservation for your application errors β€” codes outside this range raise ORA-21000 instead of your intended error.
  • 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
πŸ“Š Production Insight
Error codes outside -20000 to -20999 raise ORA-21000 β€” your custom error is replaced by an error about raise_application_error itself.
The caller sees a confusing message about 'error number argument' instead of your business logic error.
Rule: validate every error code is in range before deployment β€” a pre-deployment scan of dba_source catches out-of-range codes before they reach production.
🎯 Key Takeaway
The -20000 to -20999 range is Oracle's exclusive reservation for application-defined errors β€” 1,000 codes under your control.
Codes outside this range raise ORA-21000 instead of your intended error β€” the custom code and message are lost.
Bottom line: if your error code is not in this range, RAISE_APPLICATION_ERROR is replacing your custom error with an error about itself.

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.

io/thecodeforge/errors/ora06512_mechanics.sql Β· SQL
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
-- 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)
Mental Model
Error Stack Anatomy
The error stack is a call chain β€” each ORA-06512 frame is a level in the chain, and the custom error code at each level tells you what that level diagnosed.
  • 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
πŸ“Š Production Insight
ORA-06512 is a stack frame marker β€” not an error. It shows WHERE, not WHAT.
The actual error is the ORA-20XXX code above the ORA-06512 frame β€” always read it first.
Rule: when debugging, read the custom error code and message first. Only navigate to the ORA-06512 line number if the message does not provide sufficient diagnostic context.
🎯 Key Takeaway
ORA-06512 is a stack frame marker β€” it shows WHERE the error occurred, not WHAT the error is.
The custom error code (ORA-20XXX) is the WHAT β€” always read it first, then use ORA-06512 for source location.
Bottom line: engineers who debug ORA-06512 line numbers without reading the custom error code above the frame are debugging the wrong question.

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.

io/thecodeforge/errors/propagation_mechanics.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
-- 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
⚠ Propagation Rules
πŸ“Š Production Insight
Each re-raise with keep_error_stack=TRUE adds context to the growing error stack β€” this is the correct pattern for multi-layer systems.
WHEN OTHERS without re-raise silently swallows the error β€” the caller sees a successful return from a failed operation.
Rule: every WHEN OTHERS handler must either re-raise the exception or explicitly handle the error with a documented business reason β€” silent swallowing is the number one cause of data inconsistency.
🎯 Key Takeaway
Custom errors propagate through nested blocks identically to standard Oracle errors β€” each level can add context and re-raise.
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.
Bottom line: if your WHEN OTHERS handler does not re-raise, it is converting exceptions into silent successes β€” and your data will eventually be inconsistent.

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.

io/thecodeforge/errors/error_system.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
-- 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 System Architecture
  • 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
πŸ“Š Production Insight
A custom error system requires three synchronized layers: the database registry, the PL/SQL handler, and the application mapper.
A code change in one layer without the others causes silent misrouting β€” the application takes the wrong recovery action.
Rule: version the error registry and deploy all three layers atomically β€” a mismatch between layers is a production incident waiting to happen.
🎯 Key Takeaway
A production-grade custom error system has three layers: registry (database), handler (PL/SQL), and mapper (application).
All three must be consistent β€” a code defined in the database but missing from the application mapper results in an escalation for what should be a handled error.
Bottom line: if your error code is not in the registry, it should not exist in production code β€” enforce this with a pre-deployment check.

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.

io/thecodeforge/errors/ErrorCodeHandler.java Β· JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
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();
    }
}
Mental Model
Database-Application Error Contract
The database raises custom codes. The application maps them to recovery actions. This contract is the foundation of reliable error handling.
  • Database defines 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
πŸ“Š Production Insight
The database and application must share the same error code definitions.
A code defined in the database but missing from the application mapper results in an 'unknown error' escalation for what should be a handled condition.
Rule: the error registry is a shared contract β€” version it, review changes across teams, and deploy database and application changes atomically.
🎯 Key Takeaway
The application maps custom error codes to recovery actions β€” this database-application contract is what makes error handling reliable.
Retryable errors get backoff with limits. Permanent errors display and stop. Unknown codes escalate immediately.
Bottom line: if your application retries a -20010 (DUPLICATE_ORDER) or displays a raw ORA-20102 message to the user, the error contract is broken.

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.

io/thecodeforge/errors/stack_reading.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
-- 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;
/
πŸ’‘Stack Reading Rules for Custom Errors
  • 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
πŸ“Š Production Insight
Engineers who read only the ORA-06512 line number and ignore the custom error code are answering the wrong question β€” they know WHERE but not WHAT.
The custom code and message were placed in the stack specifically to make debugging fast β€” read them first.
Rule: read the custom error code and message before navigating to any source code β€” in most cases, the message contains everything needed to diagnose the issue.
🎯 Key Takeaway
Read the error stack bottom to top for root cause analysis β€” the deepest ORA-20XXX is the root cause, the topmost is what the caller should act on.
The custom error code is the WHAT. The ORA-06512 frame is the WHERE. The message is the WHY and with what data.
Bottom line: if you are reading only the ORA-06512 line number and opening source code before reading the custom error code and message, you are skipping the fastest path to diagnosis.
πŸ—‚ Standard Oracle Errors vs. Custom Errors (ORA-20001 to ORA-20999)
How custom errors differ from system errors in behavior, handling, and debugging approach
PropertyStandard Oracle Error (e.g., ORA-00001)Custom Error (ORA-20XXX)
Error code rangeORA-00001 to ORA-65535 (assigned by Oracle Corporation)ORA-20001 to ORA-20999 (assigned by the application developer)
Message contentFixed Oracle message β€” cannot be customized by the developerDeveloper-defined message β€” up to 2048 bytes with entity IDs and context
Who assigns the codeOracle Corporation β€” reserved per version and featureDeveloper via RAISE_APPLICATION_ERROR β€” each code maps to one failure mode
ORA-06512 behaviorAttached automatically when the error propagates through PL/SQLSame propagation mechanics β€” ORA-06512 shows the RAISE_APPLICATION_ERROR line
Stack behavior controlImplicit β€” no developer control over stack preservationExplicit β€” keep_error_stack=TRUE preserves the original error, FALSE replaces it
Application mappingDriver maps to standard exception classes (e.g., SQLIntegrityConstraintViolationException)Application must map code to recovery action via an error registry β€” no automatic mapping
Debugging approachLook up the ORA code in Oracle documentation for the standard meaningLook up the code in the application's error registry for the custom meaning and recovery hint
Retryable determinationKnown 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

    βœ•Using a single generic error code (-20001) for every failure mode in a procedure
    Symptom

    Every exception from the procedure is ORA-20001 with a generic message like 'Operation failed'. The application cannot distinguish retryable errors (deadlock) from permanent errors (duplicate order). All errors are treated identically β€” usually retried β€” which causes duplicate operations, double charges, and data corruption.

    Fix

    Define a unique error code for each distinct failure mode. Use the error registry to map each code to a severity and recovery action. Catch specific exceptions before WHEN OTHERS. Include the original SQLCODE and entity IDs in the custom message for debugging.

    βœ•Treating ORA-06512 as the error instead of reading the custom error code above it
    Symptom

    Engineers spend hours reading the procedure source at the ORA-06512 line number, trying to understand what failed by examining the code context. The custom error code (ORA-20XXX) and its diagnostic message β€” which were placed in the stack specifically to answer this question β€” are completely ignored.

    Fix

    Train the team to read the custom error code and message FIRST. Look up the code in the error registry for the severity and recovery hint. Only navigate to the ORA-06512 source line if the message does not contain sufficient diagnostic context.

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

    RAISE_APPLICATION_ERROR(-30001, 'My error') raises ORA-21000 instead of the intended custom error. The caller sees an error about raise_application_error rather than the business logic error. The developer's intended code and message are permanently lost.

    Fix

    Always use codes within -20000 to -20999 inclusive. Add a pre-deployment check that scans dba_source for RAISE_APPLICATION_ERROR calls and flags any with codes outside the valid range.

    βœ•Not registering error codes in the error registry before using them in code
    Symptom

    The application team receives ORA-20050 and has no idea what it means because it is not in the registry. They cannot map it to a recovery action. The error is treated as unknown and escalated to operations β€” even though it may be a common, well-understood validation error that the database developer intended to be handled automatically.

    Fix

    Every error code must be registered in the error registry before it appears in production code. Add a pre-deployment check that cross-references RAISE_APPLICATION_ERROR codes in dba_source against the registry and blocks deployment for any unregistered code.

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

    The error stack shows only the outermost custom error. The original Oracle error (ORA-00001, ORA-01403) and all intermediate context from deeper handlers are permanently lost. Debugging requires reading every procedure in the call chain and mentally simulating which error path was taken.

    Fix

    Use keep_error_stack=TRUE in all nested handlers. Reserve FALSE only for the outermost API entry point where you deliberately want to hide internal implementation details from the external caller.

    βœ•Not including entity IDs, data values, and original SQLCODE in the custom error message
    Symptom

    The error message says 'ORDER_NOT_FOUND' but does not include the order_id. The production team cannot determine which order failed, which customer is affected, or what the underlying Oracle error was without querying application logs and database audit trails β€” a process that adds 30 to 60 minutes to incident resolution.

    Fix

    Always include the entity ID, relevant data values, and original SQLCODE in the message: RAISE_APPLICATION_ERROR(-20001, 'ORDER_NOT_FOUND: order_id=' || p_order_id || ' customer_id=' || v_customer_id || ' | original=' || SQLCODE || ': ' || SQLERRM, TRUE).

Interview Questions on This Topic

  • QWhat is the relationship between ORA-20001 to ORA-20999 and ORA-06512?JuniorReveal
    ORA-20001 to ORA-20999 is Oracle's reserved range for application-defined custom errors, assigned via RAISE_APPLICATION_ERROR. ORA-06512 is a stack frame marker that shows the schema, object name, and line number where an exception was raised or re-raised. They appear together in the error stack: the custom error code (ORA-20XXX) is the WHAT β€” it tells you what type of failure occurred. The ORA-06512 frame is the WHERE β€” it tells you the exact location in the source code. The custom message between them is the WHY β€” it contains entity IDs, data values, and the original Oracle error code. Together they form a complete diagnostic: what failed, where it failed, and with what data.
  • QWhy is using a single generic error code for all failures considered a critical anti-pattern?Mid-levelReveal
    A single generic code (e.g., -20001 for everything) maps every distinct failure mode to the same error. The application cannot distinguish retryable errors (deadlock, timeout) from permanent errors (duplicate order, unique constraint violation). This forces the application to treat all errors identically β€” typically retrying all of them. Retrying a permanent error like a duplicate order can cause data corruption: the retry succeeds via a different code path, creating a duplicate charge or double refund. Each failure mode needs a distinct code so the application can choose the correct recovery action: retry with backoff for transient errors, display an error for validation failures, escalate for unexpected errors.
  • QHow do custom errors propagate through nested PL/SQL blocks, and what role does keep_error_stack play?Mid-levelReveal
    Custom errors propagate through nested blocks identically to standard Oracle errors. An unhandled exception in an inner block propagates to the enclosing block. Each level that catches and re-raises with RAISE_APPLICATION_ERROR adds a new custom error code and a new ORA-06512 frame to the stack. The keep_error_stack parameter (third argument to RAISE_APPLICATION_ERROR) controls whether the original error is preserved: TRUE chains the new error onto the existing stack, preserving the full error chain. FALSE (the default) replaces the stack, discarding everything below. In nested call chains, using FALSE at any level permanently destroys all diagnostic context from deeper levels. The best practice is to default to TRUE in all nested handlers and use FALSE only at the outermost API boundary.
  • QHow would you design a production-grade custom error system for a large multi-module application?SeniorReveal
    Three-layer architecture deployed atomically. First, a database error registry table that maps each code (-20000 to -20999) to a name, description, module, severity, retryable flag, and recovery hint. Partition the range: 100 codes per module with consistent sub-ranges for error types. Second, a centralized PL/SQL exception handler procedure that looks up the registry, builds a structured message with diagnostic context (entity IDs, SQLCODE, operation name), and calls RAISE_APPLICATION_ERROR with keep_error_stack=TRUE. This eliminates per-procedure boilerplate. Third, an application-side error mapper class that mirrors the registry and maps each code to a user message, severity, retry behavior, and recovery action. Add a pre-deployment check that verifies all RAISE_APPLICATION_ERROR codes in the codebase are registered in the registry β€” unregistered codes block deployment. All three layers must share the same definitions and be deployed together.
  • QHow do you read an error stack that contains multiple custom error codes interleaved with ORA-06512 frames?SeniorReveal
    Read the stack from bottom to top for root cause analysis. The deepest ORA-20XXX frame is the root cause β€” the original failure that started the chain. Each higher ORA-20XXX was added by an intermediate handler that caught the exception, added its own business context (what operation was being attempted, what entity was being processed), and re-raised with keep_error_stack=TRUE. The topmost ORA-20XXX is what the application caller sees and acts on. For each ORA-06512 frame, note the schema, object name, and line number β€” these map to the source code where the error was raised or re-raised. Look up each custom code in the error registry: the root cause code tells you what failed, the intermediate codes tell you the business context, and the recovery hint tells you what action to take.

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.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousOracle Error ORA-01858: A Non-Numeric Character Was FoundNext β†’Complete List of Common Oracle Errors That Cause ORA-06512
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged