Skip to content
Home Database ORA-01403: No Data Found – Causes, Fixes & Prevention

ORA-01403: No Data Found – Causes, Fixes & Prevention

Where developers are forged. · Structured learning · Free forever.
📍 Part of: PL/SQL → Topic 12 of 27
Master ORA-01403 — the most common PL/SQL runtime error and the #1 originating error behind ORA-06512 stack traces.
🧑‍💻 Beginner-friendly — no prior Database experience needed
In this tutorial, you'll learn
Master ORA-01403 — the most common PL/SQL runtime error and the #1 originating error behind ORA-06512 stack traces.
  • ORA-01403 fires when SELECT INTO returns zero rows — the most common PL/SQL runtime error and the most frequent originating error behind ORA-06512 stack traces
  • Propagate ORA-01403 when missing data is a bug that would cause incorrect business outcomes — handle it when missing data is a valid state where a default is acceptable
  • Five patterns never raise ORA-01403: NVL(MAX(...)), BULK COLLECT, explicit cursor, COUNT(*) pre-check, and EXISTS subquery — choose based on what information you need
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • ORA-01403 fires when SELECT INTO returns zero rows — the most common PL/SQL runtime error and the most frequent originating error behind ORA-06512 stack traces
  • The fix depends on business semantics: propagate when missing data is a bug, handle when missing data is a valid state
  • Alternatives that never raise ORA-01403: BULK COLLECT, NVL(MAX(...)), explicit cursors, or COUNT(*) pre-checks
  • Pre-flight validation catches missing reference data at batch startup — not at record 500,001
🚨 START HERE
ORA-01403 Quick Debug Cheat Sheet
Fast diagnostics for ORA-01403 in production. Run these queries against the database where the error occurred.
🟡Need to verify whether the row exists in the target table
Immediate ActionQuery the table with the same WHERE clause as the failing SELECT INTO
Commands
SELECT COUNT(*) FROM target_table WHERE key_column = 'failing_value';
SELECT * FROM target_table WHERE key_column = 'failing_value';
Fix NowIf count is 0, the row is missing — insert it or fix the upstream process that should have created it. If count is 1, the issue may be a view filter or a session-specific WHERE clause that further restricts the result.
🟡Need to find the SELECT INTO statement in a package body
Immediate ActionMap the ORA-06512 line number to source code
Commands
SELECT line, text FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND line BETWEEN 124 AND 130 ORDER BY line;
SELECT line, text FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%SELECT%INTO%' ORDER BY line;
Fix NowIdentify the exact SELECT INTO. Extract its WHERE clause. Run the same query manually with the failing parameter values to confirm zero rows.
🟡Need to find all SELECT INTO statements without NO_DATA_FOUND handlers in a package
Immediate ActionAudit the package for unhandled SELECT INTO
Commands
SELECT line, text FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%SELECT%INTO%' ORDER BY line;
SELECT line, text FROM all_source WHERE name = 'PKG_NAME' AND type = 'PACKAGE BODY' AND UPPER(text) LIKE '%NO_DATA_FOUND%' ORDER BY line;
Fix NowCompare the two result sets. Every SELECT INTO line should have a corresponding NO_DATA_FOUND handler in the same BEGIN/EXCEPTION/END block. Missing handlers are potential ORA-01403 crash points.
🟡Need to check if a view is filtering out rows that should be visible
Immediate ActionCompare the view query results with the base table
Commands
SELECT COUNT(*) FROM base_table WHERE key_column = 'failing_value';
SELECT COUNT(*) FROM view_name WHERE key_column = 'failing_value';
Fix NowIf the base table count is > 0 but the view count is 0, the view filter is excluding the row. Check the view definition: SELECT text FROM all_views WHERE view_name = 'VIEW_NAME'.
Production IncidentPayment Processing Halted for 4 Hours Due to Missing Configuration RowA new account type was deployed to production without corresponding configuration rows. Every payment for the new type raised ORA-01403, halting the nightly batch for 4 hours.
SymptomThe nightly payment processing batch failed at 02:14 AM with ORA-01403: no data found. The error stack pointed to line 127 of PAYMENT_CONFIG_PKG. No payments were processed for 4 hours until the on-call engineer identified the missing configuration.
AssumptionThe team assumed the error was a transient data issue caused by a database failover earlier that evening. They restarted the batch three times before reading the full error stack and investigating the actual cause.
Root causeA new account type (PREMIUM_SAVINGS) was deployed to production at 21:00. The deployment included the new account type in the accounts table but did not include a corresponding row in the payment_config table. The config retrieval procedure used SELECT INTO: SELECT fee_rate, processing_method INTO v_fee_rate, v_method FROM payment_config WHERE account_type = p_account_type AND active_flag = 'Y'. When the batch processed the first PREMIUM_SAVINGS account, the query returned zero rows and raised ORA-01403. The batch-level error handler logged the error and halted — it did not skip the failing record and continue processing remaining accounts.
FixThree changes. First: added a pre-flight validation procedure that checks all active account types exist in payment_config before the batch begins processing. If any are missing, the batch raises a descriptive error listing the missing types and does not start. Second: changed the config retrieval procedure to use an explicit NO_DATA_FOUND handler that logs the missing configuration with the account type and raises a custom error (ORA-20010) with context. Third: changed the batch loop to skip-and-continue — individual record failures are logged and counted, but processing continues for remaining records. A summary report at the end lists all failures with context.
Key Lesson
Pre-flight validation catches configuration gaps before they cause runtime failures — a few COUNT queries at batch startup prevent hours of wasted processingSELECT INTO without a NO_DATA_FOUND handler is a time bomb in any procedure that queries a lookup tableBatch jobs must implement skip-and-continue error handling — halting the entire batch for a single missing row wastes all preceding workDeployments that add new entity types must include all dependent reference data — add a deployment checklist item for every lookup table
Production Debug GuideFrom error message to root cause resolution
ORA-01403 with no preceding context in the error stackThe SELECT INTO is in the current anonymous block or the outermost procedure. There are no ORA-06512 entries because the error was not re-raised through nested calls. Query the table with the same WHERE clause to determine why no rows match. Check for missing reference data, filtered views that exclude the target row, or soft-delete patterns where the row exists but is logically inactive.
ORA-01403 appears with ORA-06512 entries deep in the call stackRead the error stack bottom-up using the workflow from the ORA-06512 companion article. The deepest ORA-06512 points to the SELECT INTO that failed. Map the line number to source code using ALL_SOURCE. Trace the parameter values that were passed to the failing procedure — they determine the WHERE clause that returned zero rows.
ORA-01403 occurs intermittently — the same input works sometimes and fails other timesThe query depends on data that changes between calls. Check for: race conditions where another session deletes or updates the row between operations, time-dependent views that filter by date range (the row may exist but fall outside the current date window), or sequence-dependent lookups where the row is created after the batch was already running.
ORA-01403 appears after a deployment or data migrationNew entity types were added without corresponding reference data. Query each lookup table for the failing key value: SELECT COUNT(*) FROM lookup_table WHERE key_column = 'failing_value'. Check deployment scripts for missing INSERT statements. Run the pre-flight validation procedure if one exists.
ORA-01403 masked by WHEN OTHERS handler — error log shows a different error or no errorA WHEN OTHERS handler in the call chain is catching ORA-01403 and either swallowing it (WHEN OTHERS THEN NULL) or raising a generic custom error without context. Search for WHEN OTHERS in the call chain using ALL_SOURCE. Every WHEN OTHERS must either re-raise with RAISE or log FORMAT_ERROR_STACK before raising a custom error.
ORA-01403 in a procedure that worked before a schema changeA view definition or table structure changed. Check if a view used by the SELECT INTO was altered to add a filter that excludes the target rows. Check for column renames that cause the WHERE clause to match differently. Query ALL_VIEWS for the view definition and compare with the previous version in source control.

ORA-01403: no data found occurs when a SELECT INTO statement in PL/SQL returns zero rows. The SELECT INTO construct requires exactly one row — zero rows raises ORA-01403, multiple rows raises ORA-01422. Both are runtime errors that crash the executing block unless handled.

This error dominates production error logs because SELECT INTO is the default pattern for fetching single-row results in PL/SQL. Every procedure that queries a lookup table, retrieves a configuration value, or fetches a user record is a potential ORA-01403 source. When the exception propagates through nested procedure calls, it generates ORA-06512 stack trace entries at each level — making ORA-01403 the most common originating error behind ORA-06512 stacks.

The fix is not just adding exception handling — it is choosing the right data access pattern for each scenario. Some SELECT INTO calls should raise an error when no data exists because missing data indicates a configuration bug. Others should return a default because missing data is a valid business state. Some should be replaced entirely with patterns that never raise ORA-01403 at all. This guide covers every pattern with runnable examples for Oracle 19c, 21c, and 23ai.

Why SELECT INTO Raises ORA-01403

SELECT INTO is a PL/SQL construct that enforces a strict one-row contract at runtime. Zero rows raise ORA-01403 (NO_DATA_FOUND). Multiple rows raise ORA-01422 (TOO_MANY_ROWS). Exactly one row is the only outcome that allows the block to continue executing.

The contract is enforced by the SQL engine, not by PL/SQL. Oracle executes the query, counts the result rows, and raises the appropriate exception if the count is not exactly one. This means the error bypasses any PL/SQL logic between the SELECT and the exception handler — there is no opportunity to check the row count before the exception fires.

The SELECT INTO pattern is used for single-row lookups: fetching a configuration value, retrieving a user record, reading a balance, or loading a preference. It is syntactically simple (three lines) but semantically strict — if the WHERE clause does not guarantee exactly one row for every possible input, the block can crash.

Common production sources of ORA-01403: missing reference data for new entity types added without corresponding lookup rows, filtered views that exclude rows based on status columns or date ranges, race conditions where another session deletes the row between a check and the SELECT INTO, soft-delete patterns where the row exists but has an active_flag set to N, and NULL comparisons in the WHERE clause (NULL = NULL evaluates to FALSE in SQL, so a NULL parameter never matches a NULL column value).

select_into_demo.sql · SQL
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
-- ============================================================
-- SELECT INTO: the one-row contract
-- Zero rows = ORA-01403, Multiple rows = ORA-01422
-- Runnable on Oracle 19c, 21c, 23ai
-- ============================================================

-- Setup: create a test table
CREATE TABLE demo_accounts (
  account_id   NUMBER PRIMARY KEY,
  account_type VARCHAR2(30) NOT NULL,
  balance      NUMBER(15,2) NOT NULL,
  status       VARCHAR2(10) DEFAULT 'ACTIVE'
);

INSERT INTO demo_accounts VALUES (1, 'CHECKING', 5000.00, 'ACTIVE');
INSERT INTO demo_accounts VALUES (2, 'SAVINGS', 12000.00, 'ACTIVE');
INSERT INTO demo_accounts VALUES (3, 'CHECKING', 800.00, 'CLOSED');
COMMIT;

-- ============================================================
-- Demo 1: ORA-01403 — zero rows
-- ============================================================

DECLARE
  v_balance NUMBER;
BEGIN
  -- Account 99 does not exist — this raises ORA-01403
  SELECT balance INTO v_balance
  FROM demo_accounts
  WHERE account_id = 99;

  DBMS_OUTPUT.PUT_LINE('Balance: ' || v_balance);
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    DBMS_OUTPUT.PUT_LINE('ORA-01403 caught: account 99 does not exist');
END;
/

-- ============================================================
-- Demo 2: ORA-01422 — multiple rows
-- ============================================================

DECLARE
  v_balance NUMBER;
BEGIN
  -- Two accounts have type CHECKING — this raises ORA-01422
  SELECT balance INTO v_balance
  FROM demo_accounts
  WHERE account_type = 'CHECKING';

  DBMS_OUTPUT.PUT_LINE('Balance: ' || v_balance);
EXCEPTION
  WHEN TOO_MANY_ROWS THEN
    DBMS_OUTPUT.PUT_LINE('ORA-01422 caught: multiple CHECKING accounts exist');
END;
/

-- ============================================================
-- Demo 3: NULL comparison trap
-- NULL = NULL evaluates to FALSE — SELECT INTO returns zero rows
-- ============================================================

DECLARE
  v_balance NUMBER;
  v_search_type VARCHAR2(30) := NULL;  -- NULL parameter
BEGIN
  SELECT balance INTO v_balance
  FROM demo_accounts
  WHERE account_type = v_search_type;  -- NULL = 'CHECKING' is FALSE
                                        -- NULL = NULL is also FALSE
  DBMS_OUTPUT.PUT_LINE('Balance: ' || v_balance);
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    DBMS_OUTPUT.PUT_LINE('ORA-01403 caught: NULL comparison matched zero rows');
    DBMS_OUTPUT.PUT_LINE('Fix: use NVL() or IS NULL in the WHERE clause');
END;
/

-- Cleanup
-- DROP TABLE demo_accounts PURGE;
Mental Model
The SELECT INTO Contract
SELECT INTO is a strict contract: give me exactly one row, or I crash.
  • Zero rows = ORA-01403 (NO_DATA_FOUND) — the row you expected does not exist
  • Multiple rows = ORA-01422 (TOO_MANY_ROWS) — your WHERE clause is not unique enough
  • Exactly one row = success — the only outcome where the block continues
  • The contract is enforced by the SQL engine at runtime — there is no compile-time check
  • Every SELECT INTO is a potential ORA-01403 unless the WHERE clause guarantees one row for every possible input
  • NULL comparisons are a hidden trap: NULL = anything evaluates to FALSE, so a NULL parameter matches zero rows
📊 Production Insight
SELECT INTO is the default lookup pattern because it is three lines of code. It is also the number one source of ORA-01403 in production because developers assume the row will always exist. Every SELECT INTO must include an explicit design decision: what happens when zero rows are returned? If that question was never asked, the answer is 'the block crashes at 2 AM and pages the on-call engineer.'
🎯 Key Takeaway
SELECT INTO enforces a strict one-row contract at runtime. Zero rows crashes the block with ORA-01403. Multiple rows crashes with ORA-01422. If you cannot guarantee exactly one row for every possible input, use an alternative pattern.

Propagate vs. Handle: The Business Semantics Decision

Not every ORA-01403 should be caught. The correct response depends entirely on the business semantics of missing data. Getting this decision wrong in either direction causes production failures.

Let ORA-01403 propagate when missing data indicates a bug, a configuration error, or a data integrity violation. If a payment configuration row must exist for every account type, catching ORA-01403 and returning a default payment rule hides a deployment error. The system processes payments with incorrect rules for weeks before anyone notices. The error should propagate, be logged with context, and trigger an alert.

Handle ORA-01403 when missing data is a valid business state. A new user may not have saved preferences yet. A report may have no data for the requested date range. An optional integration may not have configuration. In these cases, return a sensible default and continue processing.

The key question: would using a default value when the row is missing cause incorrect business outcomes? If yes, the missing data is a bug — propagate the error. If no, the missing data is a valid state — return a default.

A common failure mode is changing the decision over time without updating the handler. A lookup table that was once optional becomes required, but the NO_DATA_FOUND handler still returns a default. Review all NO_DATA_FOUND handlers when the business rules for a table change.

propagate_vs_handle.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
-- ============================================================
-- CASE 1: Missing data is a BUG — propagate with context
-- Payment config MUST exist for every active account type
-- ============================================================

CREATE OR REPLACE FUNCTION get_payment_config(
  p_account_type IN VARCHAR2
) RETURN payment_config%ROWTYPE IS
  v_config payment_config%ROWTYPE;
BEGIN
  SELECT * INTO v_config
  FROM payment_config
  WHERE account_type = p_account_type
    AND active_flag = 'Y';

  RETURN v_config;

EXCEPTION
  WHEN NO_DATA_FOUND THEN
    -- Do NOT return a default — this is a deployment error
    -- The batch should halt and alert the team
    RAISE_APPLICATION_ERROR(
      -20010,
      'Missing payment config for account type: ' || p_account_type
      || ' | This is a deployment error — insert the config row before retrying'
    );
END get_payment_config;
/

-- ============================================================
-- CASE 2: Missing data is a VALID STATE — return a default
-- New users have no preferences yet — this is expected
-- ============================================================

CREATE OR REPLACE FUNCTION get_theme_preference(
  p_user_id IN NUMBER
) RETURN VARCHAR2 IS
  v_theme VARCHAR2(50);
BEGIN
  SELECT theme INTO v_theme
  FROM user_preferences
  WHERE user_id = p_user_id;

  RETURN v_theme;

EXCEPTION
  WHEN NO_DATA_FOUND THEN
    -- New users have no preferences — return system default
    -- No error, no alert — this is normal behavior
    RETURN 'system_default';
END get_theme_preference;
/

-- ============================================================
-- CASE 3: Existence check without SELECT INTO
-- Caller needs to branch on whether data exists
-- Uses the NVL(MAX(...)) pattern — never raises ORA-01403
-- ============================================================

CREATE OR REPLACE FUNCTION get_account_balance_safe(
  p_account_id IN NUMBER
) RETURN NUMBER IS
  v_balance NUMBER;
BEGIN
  -- NVL(MAX(...)) returns NULL wrapped in NVL default when zero rows
  -- MAX on zero rows returns NULL (not ORA-01403)
  -- NVL converts NULL to the default value
  SELECT NVL(MAX(balance), 0)
  INTO v_balance
  FROM demo_accounts
  WHERE account_id = p_account_id;

  RETURN v_balance;
  -- Never raises ORA-01403 — MAX on zero rows returns NULL, NVL returns 0
END get_account_balance_safe;
/

-- ============================================================
-- CASE 4: Existence check using EXISTS subquery
-- Most efficient when you only need a boolean answer
-- ============================================================

CREATE OR REPLACE FUNCTION has_pending_order(
  p_customer_id IN NUMBER
) RETURN BOOLEAN IS
  v_exists NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_exists
  FROM dual
  WHERE EXISTS (
    SELECT 1 FROM orders
    WHERE customer_id = p_customer_id
      AND status = 'PENDING'
  );

  RETURN v_exists = 1;
  -- Never raises ORA-01403 — COUNT(*) FROM dual always returns one row
END has_pending_order;
/
⚠ Propagate vs. Handle Decision Framework
📊 Production Insight
The most expensive ORA-01403 bugs are the ones that are caught and silenced. A NO_DATA_FOUND handler that returns a default payment fee rate allows payments to process with incorrect fees for weeks before the revenue discrepancy is noticed. Propagating the error would have stopped the first incorrect payment and triggered an immediate fix.
🎯 Key Takeaway
The decision to propagate or handle ORA-01403 depends on whether missing data is a bug or a valid business state. Propagating valid absence causes unnecessary crashes. Handling invalid absence hides real bugs that corrupt business data. Ask: would a default value cause incorrect business outcomes?

Five Patterns That Never Raise ORA-01403

SELECT INTO is not the only way to fetch a single row. Five alternative patterns return a result without raising ORA-01403 when zero rows match. Each has different trade-offs in verbosity, performance, and semantics.

Pattern 1: NVL(MAX(...)) — the simplest alternative. Wrap the column in MAX() and the result in NVL(). MAX on zero rows returns NULL (not ORA-01403). NVL converts NULL to a default. Three lines, no exception handler, identical performance.

Pattern 2: BULK COLLECT — fetches into a collection that can be empty. Check the collection count to distinguish zero rows from one row. More verbose (eight lines) but handles both zero and multiple rows without exceptions.

Pattern 3: Explicit cursor with FETCH — open a cursor, fetch one row, check %NOTFOUND. More verbose but provides complete control over the fetch lifecycle including the ability to fetch additional rows if needed.

Pattern 4: COUNT(*) pre-check — verify the row exists before SELECT INTO. Prevents ORA-01403 entirely but requires two queries instead of one.

Pattern 5: EXISTS subquery with dual — returns a boolean existence check. Most efficient when you only need to know whether data exists, not what the data contains.

five_patterns.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
-- ============================================================
-- Pattern 1: NVL(MAX(...)) — simplest, 3 lines, no exception
-- Best for: single scalar values where zero rows means default
-- ============================================================

CREATE OR REPLACE FUNCTION get_balance_nvl_max(
  p_account_id IN NUMBER
) RETURN NUMBER IS
  v_balance NUMBER;
BEGIN
  SELECT NVL(MAX(balance), 0)
  INTO v_balance
  FROM demo_accounts
  WHERE account_id = p_account_id;

  RETURN v_balance;
  -- Zero rows: MAX returns NULL, NVL returns 0
  -- One row: MAX returns the value, NVL passes it through
  -- Never raises ORA-01403
END get_balance_nvl_max;
/

-- ============================================================
-- Pattern 2: BULK COLLECT — handles zero and multiple rows
-- Best for: lookups where you need the full row, not just a scalar
-- Note: LIMIT 1 is unnecessary when the WHERE clause uses a
--   primary key — at most one row can match. Use LIMIT 1 when
--   the WHERE clause could match multiple rows and you want only one.
-- ============================================================

CREATE OR REPLACE FUNCTION get_account_bulk(
  p_account_id IN NUMBER
) RETURN demo_accounts%ROWTYPE IS
  TYPE account_tab IS TABLE OF demo_accounts%ROWTYPE;
  v_results account_tab;
  v_empty   demo_accounts%ROWTYPE;  -- All fields NULL
BEGIN
  SELECT *
  BULK COLLECT INTO v_results
  FROM demo_accounts
  WHERE account_id = p_account_id;
  -- Primary key lookup: at most one row, LIMIT not needed

  IF v_results.COUNT = 0 THEN
    RETURN v_empty;  -- Return record with all fields NULL
  END IF;

  RETURN v_results(1);
  -- Never raises ORA-01403 — empty collection instead
END get_account_bulk;
/

-- BULK COLLECT with LIMIT — use when WHERE could match many rows
CREATE OR REPLACE FUNCTION get_first_active_account(
  p_account_type IN VARCHAR2
) RETURN demo_accounts%ROWTYPE IS
  TYPE account_tab IS TABLE OF demo_accounts%ROWTYPE;
  v_results account_tab;
  v_empty   demo_accounts%ROWTYPE;
BEGIN
  SELECT *
  BULK COLLECT INTO v_results
  FROM demo_accounts
  WHERE account_type = p_account_type
    AND status = 'ACTIVE'
  ORDER BY account_id
  FETCH FIRST 1 ROW ONLY;  -- Oracle 12c+ row limiting clause

  IF v_results.COUNT = 0 THEN
    RETURN v_empty;
  END IF;

  RETURN v_results(1);
END get_first_active_account;
/

-- ============================================================
-- Pattern 3: Explicit cursor with FETCH
-- Best for: complex logic per row, or when you may need > 1 row
-- ============================================================

CREATE OR REPLACE FUNCTION get_account_cursor(
  p_account_id IN NUMBER
) RETURN demo_accounts%ROWTYPE IS
  CURSOR c_account IS
    SELECT * FROM demo_accounts
    WHERE account_id = p_account_id;
  v_result demo_accounts%ROWTYPE;
BEGIN
  OPEN c_account;
  FETCH c_account INTO v_result;

  IF c_account%NOTFOUND THEN
    CLOSE c_account;
    -- v_result has all fields NULL — return it or handle as needed
    RETURN v_result;
  END IF;

  CLOSE c_account;
  RETURN v_result;
  -- Never raises ORA-01403 — %NOTFOUND check instead
END get_account_cursor;
/

-- ============================================================
-- Pattern 4: COUNT(*) pre-check
-- Best for: when you need to branch on existence before processing
-- Trade-off: two queries instead of one
-- ============================================================

CREATE OR REPLACE PROCEDURE process_account_with_check(
  p_account_id IN NUMBER
) IS
  v_count   NUMBER;
  v_balance NUMBER;
BEGIN
  -- First query: check existence
  SELECT COUNT(*) INTO v_count
  FROM demo_accounts
  WHERE account_id = p_account_id;

  IF v_count = 0 THEN
    DBMS_OUTPUT.PUT_LINE('Account ' || p_account_id || ' does not exist');
    RETURN;
  END IF;

  -- Second query: safe to use SELECT INTO — row guaranteed to exist
  -- Note: race condition possible if another session deletes between queries
  SELECT balance INTO v_balance
  FROM demo_accounts
  WHERE account_id = p_account_id;

  DBMS_OUTPUT.PUT_LINE('Balance: ' || v_balance);
END process_account_with_check;
/

-- ============================================================
-- Pattern 5: EXISTS subquery with dual
-- Best for: boolean existence check — no data retrieval needed
-- ============================================================

CREATE OR REPLACE FUNCTION account_exists(
  p_account_id IN NUMBER
) RETURN BOOLEAN IS
  v_exists NUMBER;
BEGIN
  SELECT COUNT(*) INTO v_exists
  FROM dual
  WHERE EXISTS (
    SELECT 1 FROM demo_accounts
    WHERE account_id = p_account_id
  );

  RETURN v_exists = 1;
  -- Never raises ORA-01403
  -- More efficient than COUNT(*) on the base table for large tables
  -- EXISTS stops scanning after the first matching row
END account_exists;
/
💡Pattern Selection Guide
Use NVL(MAX(...)) for scalar defaults — simplest and fastest. Use BULK COLLECT for full row retrieval where zero rows is valid. Use an explicit cursor when you need fine-grained fetch control. Use COUNT(*) pre-check when you need to branch on existence before processing. Use EXISTS when you only need a boolean answer. Reserve SELECT INTO for cases where zero rows is a bug and you want the exception to propagate.
📊 Production Insight
The NVL(MAX(...)) pattern is the most underused alternative in production PL/SQL. It is three lines, never raises ORA-01403, and has identical performance to SELECT INTO for single-column lookups. Teams that adopt it for scalar lookups eliminate an entire class of runtime exceptions without adding code complexity.
🎯 Key Takeaway
Five patterns replace SELECT INTO without raising ORA-01403: NVL(MAX(...)), BULK COLLECT, explicit cursor, COUNT(*) pre-check, and EXISTS subquery. Choose based on what you need: a scalar default, a full row, a boolean check, or complete fetch control.

Pre-Flight Validation: Preventing ORA-01403 Before It Happens

Pre-flight validation checks that all required reference data exists before a batch operation begins. Instead of discovering missing rows at record 500,001 when ORA-01403 crashes the batch, the validation catches gaps at startup in seconds and reports them as a structured error listing every missing key.

The pattern is straightforward: before processing, join the source data against every lookup table and identify keys that have no matching row. If any keys are missing, raise a descriptive error with the full list and abort before any records are processed.

This is critical for batch jobs that run for hours. A single missing configuration row at record 500,001 wastes all the processing time spent on the first 500,000 records. Pre-flight validation converts this from a multi-hour runtime crash to a three-second startup failure.

The cost is a small number of queries at batch startup. For batches that process millions of records over hours, the validation overhead is negligible. For short-running procedures, pre-flight validation may be unnecessary — a NO_DATA_FOUND handler is sufficient.

preflight_validation.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
-- ============================================================
-- Pre-flight validation: check all reference data before batch start
-- ============================================================

CREATE OR REPLACE PACKAGE preflight_pkg AS

  -- Validate all active account types have payment config
  PROCEDURE validate_payment_config;

  -- Generic: validate all FK values in source exist in target
  PROCEDURE validate_foreign_keys(
    p_source_table  IN VARCHAR2,
    p_source_column IN VARCHAR2,
    p_target_table  IN VARCHAR2,
    p_target_column IN VARCHAR2
  );

END preflight_pkg;
/

CREATE OR REPLACE PACKAGE BODY preflight_pkg AS

  PROCEDURE validate_payment_config IS
    v_missing_types VARCHAR2(4000);
  BEGIN
    -- Find account types that exist in accounts but not in payment_config
    SELECT LISTAGG(DISTINCT a.account_type, ', ')
           WITHIN GROUP (ORDER BY a.account_type)
    INTO v_missing_types
    FROM accounts a
    LEFT JOIN payment_config pc
      ON pc.account_type = a.account_type
      AND pc.active_flag = 'Y'
    WHERE pc.account_type IS NULL
      AND a.status = 'ACTIVE';

    IF v_missing_types IS NOT NULL THEN
      RAISE_APPLICATION_ERROR(
        -20010,
        'Pre-flight failed: missing payment config for account types: '
        || v_missing_types
        || ' | Insert config rows before running the batch.'
      );
    END IF;

    DBMS_OUTPUT.PUT_LINE('Pre-flight: payment config validated — all types present');
  END validate_payment_config;

  PROCEDURE validate_foreign_keys(
    p_source_table  IN VARCHAR2,
    p_source_column IN VARCHAR2,
    p_target_table  IN VARCHAR2,
    p_target_column IN VARCHAR2
  ) IS
    v_sql           VARCHAR2(4000);
    v_missing_count NUMBER;
  BEGIN
    -- Validate table/column names to prevent SQL injection
    -- DBMS_ASSERT.SQL_OBJECT_NAME raises ORA-44002 for invalid names
    v_sql := '
      SELECT COUNT(DISTINCT s.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_source_column) || ')
      FROM ' || DBMS_ASSERT.SQL_OBJECT_NAME(p_source_table) || ' s
      WHERE NOT EXISTS (
        SELECT 1 FROM ' || DBMS_ASSERT.SQL_OBJECT_NAME(p_target_table) || ' t
        WHERE t.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_target_column)
        || ' = s.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_source_column) || '
      )';

    EXECUTE IMMEDIATE v_sql INTO v_missing_count;

    IF v_missing_count > 0 THEN
      RAISE_APPLICATION_ERROR(
        -20011,
        'Pre-flight failed: ' || v_missing_count
        || ' distinct values in ' || p_source_table || '.' || p_source_column
        || ' have no matching row in ' || p_target_table || '.' || p_target_column
      );
    END IF;

    DBMS_OUTPUT.PUT_LINE(
      'Pre-flight: FK validation passed for '
      || p_source_table || '.' || p_source_column
      || ' -> ' || p_target_table || '.' || p_target_column
    );
  END validate_foreign_keys;

END preflight_pkg;
/

-- ============================================================
-- Usage: call at batch startup before any processing
-- ============================================================

BEGIN
  -- Validate all reference data
  preflight_pkg.validate_payment_config;
  preflight_pkg.validate_foreign_keys('ACCOUNTS', 'REGION_CODE', 'REGIONS', 'REGION_CODE');
  preflight_pkg.validate_foreign_keys('ORDERS', 'PRODUCT_ID', 'PRODUCTS', 'PRODUCT_ID');

  -- All validations passed — safe to process
  DBMS_OUTPUT.PUT_LINE('All pre-flight checks passed — starting batch');
  -- payment_batch_pkg.process_nightly;

EXCEPTION
  WHEN OTHERS THEN
    DBMS_OUTPUT.PUT_LINE('Pre-flight failed: ' || SQLERRM);
    -- Log and alert — do not start the batch
    RAISE;
END;
/
Mental Model
Pre-Flight vs. Runtime Error Detection
Pre-flight validation shifts error detection from runtime — when the damage is done — to startup — when nothing has been processed yet.
  • Runtime ORA-01403: batch processes 500,000 records, fails at record 500,001, wastes hours of processing time and may leave data in a partially processed state
  • Pre-flight validation: batch checks all reference keys in 3 seconds, fails with a complete list of missing keys, zero records processed, clean state maintained
  • Cost: a few COUNT or EXISTS queries at startup — negligible compared to the batch processing time
  • Benefit: converts cryptic ORA-01403 stack traces into structured errors listing exactly which keys are missing
📊 Production Insight
The pre-flight pattern also serves as documentation. The validation queries define every lookup dependency the batch has. When a new lookup table is added, adding a pre-flight check documents the dependency and protects against missing data in the same step.
🎯 Key Takeaway
Pre-flight validation shifts ORA-01403 detection from runtime to startup. A few COUNT queries at batch start prevent hours of wasted processing. Every lookup dependency should have a corresponding pre-flight check.

Batch Skip-and-Continue: Isolating ORA-01403 Without Halting

Batch jobs that halt on the first ORA-01403 waste all processing time spent on previous records. The skip-and-continue pattern catches the exception for the failing record, logs it with context, and continues processing remaining records.

The pattern has three components: a record-level exception handler inside the loop, an error counter that tracks failures, and a summary report at the end that lists all failures with context. This allows the batch to complete processing for all valid records while preserving detailed error information for the failures.

The record-level handler uses WHEN OTHERS (not just WHEN NO_DATA_FOUND) because other exceptions may also occur during processing. The handler logs the error using an autonomous transaction procedure so the log entry survives even if the record's transaction is rolled back.

The summary report at the end determines the batch outcome: if error count is zero, the batch succeeded. If error count is above a threshold, the batch raises an alert. If error count equals the total record count, something is fundamentally wrong and the batch should be investigated immediately.

batch_skip_continue.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
-- ============================================================
-- Skip-and-continue batch processing pattern
-- Individual record failures are logged; batch continues
-- ============================================================

CREATE OR REPLACE PACKAGE batch_processor_pkg AS

  PROCEDURE process_payment_batch(
    p_batch_date      IN DATE,
    p_processed_count OUT NUMBER,
    p_error_count     OUT NUMBER
  );

END batch_processor_pkg;
/

CREATE OR REPLACE PACKAGE BODY batch_processor_pkg AS

  -- Autonomous transaction logging — survives rollback of the record
  PROCEDURE log_batch_error(
    p_batch_date  IN DATE,
    p_account_id  IN NUMBER,
    p_error_code  IN NUMBER,
    p_error_msg   IN VARCHAR2,
    p_error_stack IN VARCHAR2
  ) IS
    PRAGMA AUTONOMOUS_TRANSACTION;
  BEGIN
    INSERT INTO batch_error_log (
      batch_date,
      account_id,
      error_code,
      error_message,
      error_stack,
      created_at
    ) VALUES (
      p_batch_date,
      p_account_id,
      p_error_code,
      p_error_msg,
      p_error_stack,
      SYSTIMESTAMP
    );
    COMMIT;  -- Commits only this autonomous transaction
  END log_batch_error;

  PROCEDURE process_payment_batch(
    p_batch_date      IN DATE,
    p_processed_count OUT NUMBER,
    p_error_count     OUT NUMBER
  ) IS
    CURSOR c_accounts IS
      SELECT account_id, account_type, balance
      FROM accounts
      WHERE status = 'ACTIVE'
      ORDER BY account_id;

    v_config  payment_config%ROWTYPE;
    v_fee     NUMBER;
  BEGIN
    p_processed_count := 0;
    p_error_count     := 0;

    FOR rec IN c_accounts LOOP
      BEGIN
        -- This SELECT INTO may raise ORA-01403 for new account types
        SELECT * INTO v_config
        FROM payment_config
        WHERE account_type = rec.account_type
          AND active_flag = 'Y';

        -- Process the payment using the config
        v_fee := rec.balance * v_config.fee_rate;

        UPDATE payment_transactions
        SET fee_amount = v_fee,
            status     = 'PROCESSED',
            processed_at = SYSTIMESTAMP
        WHERE account_id = rec.account_id
          AND batch_date = p_batch_date;

        p_processed_count := p_processed_count + 1;

        -- Commit per record or per batch of N records
        IF MOD(p_processed_count, 1000) = 0 THEN
          COMMIT;
        END IF;

      EXCEPTION
        WHEN OTHERS THEN
          -- Skip this record — log the error and continue
          p_error_count := p_error_count + 1;

          log_batch_error(
            p_batch_date  => p_batch_date,
            p_account_id  => rec.account_id,
            p_error_code  => SQLCODE,
            p_error_msg   => SQLERRM,
            p_error_stack => DBMS_UTILITY.FORMAT_ERROR_STACK
                          || CHR(10)
                          || DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
          );

          -- Rollback only the current record's changes
          -- Previous committed batches are preserved
      END;
    END LOOP;

    -- Final commit for remaining records
    COMMIT;

    -- Summary output
    DBMS_OUTPUT.PUT_LINE(
      'Batch complete: ' || p_processed_count || ' processed, '
      || p_error_count || ' errors'
    );

    -- Alert if error rate exceeds threshold
    IF p_error_count > 0 AND p_processed_count > 0 THEN
      IF (p_error_count / (p_processed_count + p_error_count)) > 0.05 THEN
        -- More than 5% failure rate — raise alert
        RAISE_APPLICATION_ERROR(
          -20020,
          'Batch completed with high error rate: '
          || p_error_count || ' errors out of '
          || (p_processed_count + p_error_count) || ' total records ('
          || ROUND(100 * p_error_count / (p_processed_count + p_error_count), 1)
          || '%). Check batch_error_log for details.'
        );
      END IF;
    END IF;

  END process_payment_batch;

END batch_processor_pkg;
/

-- ============================================================
-- Batch error log table
-- ============================================================

CREATE TABLE batch_error_log (
  log_id        NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  batch_date    DATE NOT NULL,
  account_id    NUMBER,
  error_code    NUMBER,
  error_message VARCHAR2(4000),
  error_stack   VARCHAR2(4000),
  created_at    TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP
);

CREATE INDEX idx_batch_error_date ON batch_error_log(batch_date DESC);
CREATE INDEX idx_batch_error_account ON batch_error_log(account_id);
⚠ Skip-and-Continue Design Rules
📊 Production Insight
A batch that halts on the first error processes zero records after the failure point. A batch with skip-and-continue processes all valid records and produces a detailed error report for the failures. The error report often reveals that all failures share a single root cause — a missing config row, a bad data migration, or a schema change. Fixing one root cause resolves all logged errors.
🎯 Key Takeaway
Skip-and-continue isolates failing records without halting the batch. Log errors with autonomous transactions. Set a failure rate threshold to catch systematic issues. The error log often reveals a single root cause behind all failures.

Handling Both NO_DATA_FOUND and TOO_MANY_ROWS

SELECT INTO can fail in two ways: zero rows (ORA-01403 / NO_DATA_FOUND) or multiple rows (ORA-01422 / TOO_MANY_ROWS). Both crash the block, but they indicate fundamentally different problems.

NO_DATA_FOUND means the WHERE clause did not match any row. The data does not exist. The fix depends on business semantics — return a default, raise a custom error, or pre-validate the data.

TOO_MANY_ROWS means the WHERE clause matched more than one row. This is always a bug — the query is not unique enough, or a unique constraint is missing or disabled. The fix is to add a unique constraint, refine the WHERE clause, or use BULK COLLECT to handle multiple rows explicitly.

Production systems commonly handle NO_DATA_FOUND but ignore TOO_MANY_ROWS. This is dangerous — if a unique constraint is dropped, disabled during a data migration, or never existed, TOO_MANY_ROWS crashes the block with no handler and no context. Every SELECT INTO should consider both failure modes unless the unique constraint is verified to exist and be enforced.

both_handlers.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
-- ============================================================
-- Complete SELECT INTO with both failure modes handled
-- ============================================================

CREATE OR REPLACE FUNCTION get_account_details(
  p_account_id IN NUMBER
) RETURN demo_accounts%ROWTYPE IS
  v_result demo_accounts%ROWTYPE;
BEGIN
  SELECT * INTO v_result
  FROM demo_accounts
  WHERE account_id = p_account_id;

  RETURN v_result;

EXCEPTION
  WHEN NO_DATA_FOUND THEN
    -- Zero rows: account does not exist
    RAISE_APPLICATION_ERROR(
      -20002,
      'Account not found: ' || p_account_id
    );

  WHEN TOO_MANY_ROWS THEN
    -- Multiple rows: unique constraint violation or missing constraint
    -- This should never happen if account_id has a unique constraint
    RAISE_APPLICATION_ERROR(
      -20003,
      'Duplicate account_id detected: ' || p_account_id
      || ' | Verify unique constraint on demo_accounts.account_id'
    );
END get_account_details;
/

-- ============================================================
-- Verify unique constraints exist on lookup columns
-- Run this audit query periodically
-- ============================================================

SELECT
  t.table_name,
  t.column_name,
  CASE
    WHEN c.constraint_type IS NOT NULL THEN 'UNIQUE CONSTRAINT EXISTS'
    ELSE 'NO UNIQUE CONSTRAINT — potential TOO_MANY_ROWS risk'
  END AS constraint_status
FROM (
  -- Tables and columns used in SELECT INTO statements
  -- Update this list based on your codebase audit
  SELECT 'DEMO_ACCOUNTS' AS table_name, 'ACCOUNT_ID' AS column_name FROM dual
  UNION ALL
  SELECT 'PAYMENT_CONFIG', 'ACCOUNT_TYPE' FROM dual
  UNION ALL
  SELECT 'USER_PREFERENCES', 'USER_ID' FROM dual
) t
LEFT JOIN (
  SELECT
    acc.table_name,
    acc.column_name,
    con.constraint_type
  FROM all_cons_columns acc
  JOIN all_constraints con
    ON con.constraint_name = acc.constraint_name
    AND con.owner = acc.owner
  WHERE con.constraint_type IN ('P', 'U')  -- Primary key or Unique
) c
  ON c.table_name = t.table_name
  AND c.column_name = t.column_name
ORDER BY constraint_status DESC, t.table_name;
🔥NO_DATA_FOUND vs. TOO_MANY_ROWS Diagnostic
NO_DATA_FOUND: the data does not exist — check the WHERE clause parameters, view filters, and reference table contents. May be expected or a bug depending on business semantics. TOO_MANY_ROWS: the data is duplicated — check for missing or disabled unique constraints, duplicate rows from data migrations, or WHERE clauses that do not include all columns of the unique key. Always a bug.
📊 Production Insight
Audit every SELECT INTO in your codebase for both handlers. A SELECT INTO that handles NO_DATA_FOUND but not TOO_MANY_ROWS will crash without context if a unique constraint is dropped during a data migration. The constraint audit query above identifies tables where TOO_MANY_ROWS is a risk.
🎯 Key Takeaway
SELECT INTO can fail two ways: zero rows (ORA-01403) and multiple rows (ORA-01422). Handle both unless the unique constraint is verified to exist. NO_DATA_FOUND may be expected or a bug. TOO_MANY_ROWS is always a bug.

Testing ORA-01403 Exception Handlers

Exception handlers that have never been tested will fail when they are needed most. A NO_DATA_FOUND handler with a logic error is worse than no handler at all — it silently returns incorrect values and hides the real failure.

Test every NO_DATA_FOUND handler by forcing the exception: pass a key value that does not exist in the table and verify the handler produces the correct output, return value, and log entry. Test every TOO_MANY_ROWS handler by temporarily inserting a duplicate row.

Test batch skip-and-continue behavior by creating a controlled set of records where some are valid and some are missing reference data. Verify that the batch processes all valid records, logs all failures with correct context, and reports the correct counts.

Automate these tests in your test suite. Manual testing of error paths is unreliable because developers naturally test the happy path and skip error scenarios.

test_exception_handlers.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
-- ============================================================
-- Test framework for ORA-01403 handlers
-- Each test forces the exception and verifies handler behavior
-- ============================================================

-- Test 1: Default value handler returns correct default
CREATE OR REPLACE PROCEDURE test_theme_default IS
  v_theme VARCHAR2(50);
BEGIN
  -- User -1 does not exist — forces NO_DATA_FOUND
  v_theme := get_theme_preference(-1);

  IF v_theme != 'system_default' THEN
    RAISE_APPLICATION_ERROR(
      -20901,
      'FAIL: expected system_default, got ' || NVL(v_theme, 'NULL')
    );
  END IF;

  DBMS_OUTPUT.PUT_LINE('PASS: get_theme_preference returns default for missing user');
END test_theme_default;
/

-- Test 2: Propagation handler raises correct custom error code
CREATE OR REPLACE PROCEDURE test_missing_config_error IS
BEGIN
  BEGIN
    -- Account type that does not exist — forces NO_DATA_FOUND
    DECLARE
      v_config payment_config%ROWTYPE;
    BEGIN
      v_config := get_payment_config('NONEXISTENT_TYPE_XYZ');
      -- If we get here, the handler did not raise — test fails
      RAISE_APPLICATION_ERROR(-20902, 'FAIL: expected exception but got success');
    END;
  EXCEPTION
    WHEN OTHERS THEN
      IF SQLCODE = -20010 THEN
        -- Correct custom error code from the handler
        DBMS_OUTPUT.PUT_LINE('PASS: get_payment_config raises -20010 for missing config');
      ELSIF SQLCODE = -20902 THEN
        -- The handler did not raise — it returned a value
        DBMS_OUTPUT.PUT_LINE('FAIL: handler did not raise exception');
        RAISE;
      ELSE
        -- Wrong error code
        DBMS_OUTPUT.PUT_LINE('FAIL: expected -20010, got ' || SQLCODE);
        RAISE;
      END IF;
  END;
END test_missing_config_error;
/

-- Test 3: Batch skip-and-continue processes valid records and logs failures
CREATE OR REPLACE PROCEDURE test_batch_skip_continue IS
  v_processed NUMBER;
  v_errors    NUMBER;
BEGIN
  -- Setup: insert test accounts with one missing config
  INSERT INTO accounts (account_id, account_type, balance, status)
  VALUES (90001, 'CHECKING', 1000, 'ACTIVE');
  INSERT INTO accounts (account_id, account_type, balance, status)
  VALUES (90002, 'MISSING_TYPE_XYZ', 2000, 'ACTIVE');  -- No config for this type
  INSERT INTO accounts (account_id, account_type, balance, status)
  VALUES (90003, 'SAVINGS', 3000, 'ACTIVE');
  COMMIT;

  -- Run batch
  batch_processor_pkg.process_payment_batch(
    p_batch_date      => TRUNC(SYSDATE),
    p_processed_count => v_processed,
    p_error_count     => v_errors
  );

  -- Verify: 2 processed, 1 error
  IF v_processed != 2 THEN
    RAISE_APPLICATION_ERROR(-20903, 'FAIL: expected 2 processed, got ' || v_processed);
  END IF;

  IF v_errors != 1 THEN
    RAISE_APPLICATION_ERROR(-20904, 'FAIL: expected 1 error, got ' || v_errors);
  END IF;

  -- Verify error was logged
  DECLARE
    v_log_count NUMBER;
  BEGIN
    SELECT COUNT(*) INTO v_log_count
    FROM batch_error_log
    WHERE account_id = 90002
      AND batch_date = TRUNC(SYSDATE);

    IF v_log_count = 0 THEN
      RAISE_APPLICATION_ERROR(-20905, 'FAIL: error not logged for account 90002');
    END IF;
  END;

  DBMS_OUTPUT.PUT_LINE('PASS: batch skips failing record, processes valid ones, logs error');

  -- Cleanup
  DELETE FROM accounts WHERE account_id BETWEEN 90001 AND 90003;
  DELETE FROM batch_error_log WHERE account_id = 90002;
  COMMIT;

EXCEPTION
  WHEN OTHERS THEN
    -- Cleanup on failure
    DELETE FROM accounts WHERE account_id BETWEEN 90001 AND 90003;
    DELETE FROM batch_error_log WHERE account_id = 90002;
    COMMIT;
    RAISE;
END test_batch_skip_continue;
/

-- ============================================================
-- Run all tests
-- ============================================================

BEGIN
  DBMS_OUTPUT.PUT_LINE('=== ORA-01403 Handler Tests ===');
  DBMS_OUTPUT.PUT_LINE('');
  test_theme_default;
  test_missing_config_error;
  test_batch_skip_continue;
  DBMS_OUTPUT.PUT_LINE('');
  DBMS_OUTPUT.PUT_LINE('=== All tests complete ===');
END;
/
💡Exception Handler Testing Rules
  • Test every NO_DATA_FOUND handler by passing a key value that does not exist in the table
  • Verify the handler produces the correct return value, not just that it does not crash
  • Test batch skip-and-continue by creating records with missing reference data — verify valid records are processed and errors are logged with context
  • Test TOO_MANY_ROWS handlers by temporarily inserting a duplicate row (remove it after the test)
  • Automate exception tests — manual testing of error paths is unreliable because developers naturally test the happy path
📊 Production Insight
In the payment batch incident, the NO_DATA_FOUND handler existed but had never been executed in any test. When it finally ran in production, it logged to the wrong table and the error message was missing the account type — making diagnosis take hours instead of minutes. Testing the handler before deployment would have caught both issues.
🎯 Key Takeaway
Untested exception handlers are the most dangerous code in production. Force every NO_DATA_FOUND and TOO_MANY_ROWS handler in a test to verify correct behavior — return values, log entries, and error messages. If a handler has never been triggered in a test, it has never been validated.

ORA-01403 Through Application Drivers

Most developers encounter ORA-01403 through an application layer — Java/JDBC, Python, or Node.js — not through SQL*Plus. Each driver wraps ORA-01403 in its own exception type, but the error code and message are preserved.

In all drivers, the numeric error code 1403 is accessible as a property on the exception object. The error message includes the text 'ORA-01403: no data found'. If the ORA-01403 propagates through nested PL/SQL calls, the message also includes the ORA-06512 call chain.

Application-level exception handling should distinguish between ORA-01403 (data does not exist — may be a 404 in a REST API) and other Oracle errors (unexpected — should be a 500). Use the numeric error code for this distinction, not string matching on the error message.

For structured logging, extract the Oracle error code from the driver exception and include it as a separate field in log entries. This enables filtering error logs by Oracle error code across all application services.

driver_error_handling.txt · TEXT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
============================================================
JAVA / JDBC
============================================================

try {
    CallableStatement cs = conn.prepareCall(
        "BEGIN ? := get_payment_config(?); END;"
    );
    cs.registerOutParameter(1, Types.OTHER);
    cs.setString(2, "PREMIUM_SAVINGS");
    cs.execute();
} catch (SQLException e) {
    if (e.getErrorCode() == 1403) {
        // ORA-01403: no data found
        // In a REST API: return 404
        logger.warn("Data not found", Map.of(
            "oraCode", e.getErrorCode(),
            "procedure", "get_payment_config",
            "param", "PREMIUM_SAVINGS"
        ));
    } else if (e.getErrorCode() == 20010) {
        // Custom error from RAISE_APPLICATION_ERROR
        // Error code 20010 = missing payment config (our convention)
        logger.error("Configuration error", Map.of(
            "oraCode", e.getErrorCode(),
            "message", e.getMessage()
        ));
    } else {
        // Unexpected Oracle error
        logger.error("Database error", Map.of(
            "oraCode", e.getErrorCode(),
            "message", e.getMessage()
        ));
        throw e;  // Rethrow unexpected errors
    }
}


============================================================
PYTHON / python-oracledb
============================================================

import oracledb

try:
    cursor.callfunc('get_payment_config', oracledb.OBJECT,
                    ['PREMIUM_SAVINGS'])
except oracledb.DatabaseError as e:
    error = e.args[0]
    if error.code == 1403:
        # ORA-01403: no data found
        logger.warning('Data not found',
            extra={'ora_code': error.code,
                   'procedure': 'get_payment_config'})
    elif error.code == 20010:
        # Custom error: missing payment config
        logger.error('Configuration error',
            extra={'ora_code': error.code,
                   'message': error.message})
    else:
        logger.error('Database error',
            extra={'ora_code': error.code,
                   'message': error.message})
        raise


============================================================
NODE.JS / node-oracledb
============================================================

const oracledb = require('oracledb');

try {
    const result = await connection.execute(
        'BEGIN :result := get_payment_config(:acct_type); END;',
        { result: { dir: oracledb.BIND_OUT, type: oracledb.STRING },
          acct_type: 'PREMIUM_SAVINGS' }
    );
} catch (err) {
    if (err.errorNum === 1403) {
        // ORA-01403: no data found
        console.warn(JSON.stringify({
            level: 'warn',
            oraCode: err.errorNum,
            procedure: 'get_payment_config'
        }));
    } else if (err.errorNum === 20010) {
        // Custom error: missing payment config
        console.error(JSON.stringify({
            level: 'error',
            oraCode: err.errorNum,
            message: err.message
        }));
    } else {
        console.error(JSON.stringify({
            level: 'error',
            oraCode: err.errorNum,
            message: err.message
        }));
        throw err;
    }
}


============================================================
Key points:
============================================================
1. Error code 1403 = ORA-01403 (NO_DATA_FOUND)
2. Error codes 20000-20999 = RAISE_APPLICATION_ERROR custom errors
3. Use the numeric error code for branching — not string matching
4. Log the Oracle error code as a separate field for filtering
5. Map ORA-01403 to HTTP 404 in REST APIs when appropriate
6. Map custom errors to specific HTTP codes based on your convention
💡Application Layer Error Mapping
Map Oracle error codes to application-level responses consistently. ORA-01403 (data not found) maps to HTTP 404 when the missing data was requested by the user. Custom errors from RAISE_APPLICATION_ERROR (-20001 to -20009 for validation) map to HTTP 400. Custom errors -20010 to -20019 (not found) map to HTTP 404. System errors map to HTTP 500. Document this mapping and enforce it across all services.
📊 Production Insight
Application developers who catch all Oracle exceptions as generic 500 errors lose the diagnostic value of the Oracle error code. Mapping ORA-01403 to a specific application error (not found vs. server error) enables faster triage and better user-facing error messages. The Oracle error code is the most precise diagnostic available — preserve it through the entire logging pipeline.
🎯 Key Takeaway
Oracle error codes are preserved through all application drivers. Use the numeric error code for programmatic branching — not string matching on the message. Map ORA-01403 to HTTP 404 in REST APIs. Log the Oracle error code as a separate field for cross-service filtering.
🗂 SELECT INTO Alternatives for Single-Row Lookups
Trade-offs across different data access patterns — choose based on business semantics
PatternZero Rows BehaviorMultiple Rows BehaviorCode ComplexityBest Use Case
SELECT INTORaises ORA-01403Raises ORA-01422Low (3 lines)Zero rows is a bug — you want the exception to propagate and alert
NVL(MAX(col))Returns NVL default valueReturns MAX of all rowsLow (3 lines)Scalar default — simplest pattern that never raises ORA-01403
BULK COLLECTReturns empty collectionReturns all rows (or LIMIT N)Medium (8 lines)Full row retrieval where zero rows is valid
Explicit cursor FETCHcursor%NOTFOUND = TRUEFetches first row onlyHigh (10+ lines)Complex per-row logic or multi-row sequential processing
COUNT(*) pre-checkReturns 0 — skip SELECT INTOReturns count > 1 — handle duplicateMedium (6 lines)Caller needs to branch on existence before processing
EXISTS subqueryReturns FALSEReturns TRUE (stops at first match)Low (4 lines)Boolean existence check — no data retrieval needed

🎯 Key Takeaways

  • ORA-01403 fires when SELECT INTO returns zero rows — the most common PL/SQL runtime error and the most frequent originating error behind ORA-06512 stack traces
  • Propagate ORA-01403 when missing data is a bug that would cause incorrect business outcomes — handle it when missing data is a valid state where a default is acceptable
  • Five patterns never raise ORA-01403: NVL(MAX(...)), BULK COLLECT, explicit cursor, COUNT(*) pre-check, and EXISTS subquery — choose based on what information you need
  • Pre-flight validation catches missing reference data at batch startup in seconds — not at record 500,001 after hours of processing
  • Skip-and-continue batch processing isolates failing records without halting — log with autonomous transactions and report all failures at the end
  • Handle both NO_DATA_FOUND and TOO_MANY_ROWS for every SELECT INTO unless the unique constraint is verified and enforced
  • Test every exception handler by forcing the exception — untested handlers fail in production when they are needed most

⚠ Common Mistakes to Avoid

    Using WHEN OTHERS instead of WHEN NO_DATA_FOUND
    Symptom

    All exceptions are caught by the WHEN OTHERS handler, including unexpected errors like constraint violations, permission errors, and resource exhaustion. The handler returns a default value for every error type, silently masking bugs that should crash loudly.

    Fix

    Use specific exception handlers: WHEN NO_DATA_FOUND, WHEN TOO_MANY_ROWS, WHEN DUP_VAL_ON_INDEX. Place WHEN OTHERS last and use it only for logging the full error stack with FORMAT_ERROR_STACK and re-raising with RAISE. Never return a default from WHEN OTHERS.

    Catching ORA-01403 and returning a default when missing data is a configuration bug
    Symptom

    The system processes transactions with incorrect default values for weeks. Revenue calculations are wrong. No error is logged because the NO_DATA_FOUND handler returned a default payment fee rate instead of raising an error.

    Fix

    Evaluate whether missing data is a bug or a valid business state. If missing data would cause incorrect business outcomes (wrong fees, wrong calculations, wrong routing), propagate the error with context using RAISE_APPLICATION_ERROR. Never return a default for data that must exist.

    Not handling TOO_MANY_ROWS alongside NO_DATA_FOUND
    Symptom

    A unique constraint is dropped or disabled during a data migration. TOO_MANY_ROWS crashes the block with no handler, no context, and no guidance on what went wrong. The error stack shows ORA-01422 but the engineer does not know which constraint was violated.

    Fix

    Add WHEN TOO_MANY_ROWS handlers to every SELECT INTO that does not have a verified, enforced unique constraint. Log the duplicate key value in the error message. Periodically audit unique constraints on all tables used in SELECT INTO statements.

    Testing only the happy path — never forcing ORA-01403
    Symptom

    The NO_DATA_FOUND handler has a logic error that is only discovered during a production incident. The handler logs to the wrong table, returns an incorrect default, or raises a different exception than intended.

    Fix

    Create test cases that force NO_DATA_FOUND by passing non-existent key values. Verify the handler produces the correct return value, log entry, and error message. Automate these tests — manual testing of error paths is unreliable because developers naturally focus on the happy path.

    Using SELECT INTO for queries that may legitimately return zero rows
    Symptom

    ORA-01403 is raised on every call for new users, empty date ranges, or optional configurations. The exception handler fires constantly, generating noise in error logs and adding exception overhead to every call.

    Fix

    Use NVL(MAX(...)) for scalar defaults, BULK COLLECT for full row retrieval, or EXISTS for boolean checks. These patterns handle zero rows without exceptions. Reserve SELECT INTO for cases where zero rows is genuinely a bug.

    Batch job halts on the first ORA-01403 instead of skipping and continuing
    Symptom

    A batch processing 500,000 records fails at record 500,001 due to a single missing configuration row. All 500,000 previously processed records may need reprocessing depending on the commit strategy. The batch run time is wasted.

    Fix

    Implement skip-and-continue error handling: catch exceptions inside the record loop, log the failure with context using an autonomous transaction procedure, and continue processing. Set a failure rate threshold to catch systematic issues. Report all failures at the end of the batch.

Interview Questions on This Topic

  • QWhat causes ORA-01403 and how do you handle it in PL/SQL?JuniorReveal
    ORA-01403 is raised when a SELECT INTO statement returns zero rows. The SELECT INTO construct requires exactly one row — zero raises ORA-01403, multiple raises ORA-01422. Handle it by adding a WHEN NO_DATA_FOUND handler in the block containing the SELECT INTO. The handler should return a default value when missing data is a valid business state, or raise a custom error with context via RAISE_APPLICATION_ERROR when missing data is a bug. The decision between propagating and handling depends entirely on business semantics — would a default value cause incorrect outcomes?
  • QWhen should you let ORA-01403 propagate versus catching it?Mid-levelReveal
    Let it propagate when missing data indicates a configuration error, deployment mistake, or data integrity violation — for example, a missing payment configuration row is a deployment error that should halt processing and alert the team. Catch it when missing data is a valid business state — a new user with no saved preferences should get a default theme, not crash the application. The key question is: would using a default value when the row is missing cause incorrect business outcomes? If yes, propagate. If no, handle. Additionally, if a lookup table transitions from optional to required over time, review all its NO_DATA_FOUND handlers — they may need to change from defaulting to propagating.
  • QWhat alternatives to SELECT INTO never raise ORA-01403?Mid-levelReveal
    Five patterns. NVL(MAX(column)) wraps the column in MAX (which returns NULL on zero rows instead of ORA-01403) and NVL converts the NULL to a default — simplest pattern, three lines. BULK COLLECT fetches into a collection that can be empty — check the count for zero rows. Explicit cursor with FETCH — check %NOTFOUND after the fetch. COUNT(*) pre-check verifies the row exists before SELECT INTO. EXISTS subquery returns a boolean without fetching data. Each has different trade-offs: NVL(MAX) for scalar defaults, BULK COLLECT for full rows, EXISTS for boolean checks. Choose based on what information you need, not performance — they are all identical for single-row lookups.
  • QHow do you prevent ORA-01403 from halting a batch job?SeniorReveal
    Two complementary strategies. First: pre-flight validation at batch startup — join the source data against every lookup table and verify all keys have matching rows before processing begins. This catches systematic issues like missing configuration for new entity types in seconds, before any records are processed. Second: skip-and-continue error handling inside the processing loop — catch ORA-01403 (and WHEN OTHERS) at the record level, log the failure with context using an autonomous transaction procedure, and continue to the next record. Set a failure rate threshold — if more than a configurable percentage of records fail, raise an alert because something systematic is wrong. The batch completes for all valid records and produces a failure report at the end.
  • QHow does ORA-01403 relate to ORA-06512 in an error stack?Mid-levelReveal
    ORA-01403 is the originating error — it tells you what happened (no data found from a SELECT INTO). ORA-06512 entries that follow in the stack are call chain pointers — they tell you where the error propagated through nested procedure calls. Read the stack bottom-up: the deepest ORA-06512 is the SELECT INTO that raised ORA-01403. Map that line number to source code using ALL_SOURCE to find the exact query. Then trace the parameter values to determine why the WHERE clause returned zero rows. ORA-01403 is the single most common originating error behind ORA-06512 stacks in production Oracle systems.

Frequently Asked Questions

What is the difference between ORA-01403 and ORA-01422?

ORA-01403 (NO_DATA_FOUND) is raised when a SELECT INTO returns zero rows — the data does not exist. ORA-01422 (TOO_MANY_ROWS) is raised when a SELECT INTO returns more than one row — the query is not unique enough. Both crash the executing block. ORA-01403 may be expected or a bug depending on business semantics. ORA-01422 is always a bug — it indicates a missing or disabled unique constraint, or an insufficiently filtered WHERE clause. Handle both with specific exception handlers.

Can I use COUNT(*) to prevent ORA-01403?

Yes. Execute SELECT COUNT(*) INTO v_count before the SELECT INTO to verify the row exists. If count is zero, return a default or raise a custom error without triggering ORA-01403. If count is greater than one, handle the duplicate before SELECT INTO raises ORA-01422. The trade-off is two queries instead of one. For high-frequency lookups, NVL(MAX(...)) is more efficient because it uses a single query. For existence checks where you do not need the data, use EXISTS — it stops scanning after the first matching row.

How does ORA-01403 relate to ORA-06512?

ORA-01403 is the originating error — it reports what failed (no data found from a SELECT INTO). ORA-06512 entries in the error stack are call chain pointers — they report where the error propagated through nested PL/SQL calls. When a SELECT INTO raises ORA-01403 deep in a call chain, the error stack shows ORA-01403 followed by ORA-06512 at each procedure level. Read the stack bottom-up: the deepest ORA-06512 points to the SELECT INTO that failed. See the companion article 'How to Read and Understand the Oracle Error Stack' for the complete stack reading workflow.

Does BULK COLLECT have a performance penalty compared to SELECT INTO?

For single-row lookups by primary key, the performance difference between BULK COLLECT and SELECT INTO is negligible — Oracle optimizes both identically. The overhead of creating a collection object is trivially small compared to the SQL execution time. The real difference is in semantics: SELECT INTO raises an exception on zero rows, BULK COLLECT returns an empty collection. Choose based on whether zero rows is a bug (SELECT INTO) or a valid state (BULK COLLECT), not based on performance.

What is the NVL(MAX(...)) pattern and when should I use it?

NVL(MAX(column), default_value) is a three-line pattern that never raises ORA-01403. MAX on zero rows returns NULL instead of raising NO_DATA_FOUND. NVL converts the NULL to your default value. Example: SELECT NVL(MAX(balance), 0) INTO v_balance FROM accounts WHERE account_id = p_id. Use it for scalar lookups where zero rows means 'use a default value.' Do not use it for full row retrieval (use BULK COLLECT) or for existence checks (use EXISTS).

Should I always handle NO_DATA_FOUND?

No. Handle it when missing data is a valid business state — new users without preferences, reports with no data for a date range, optional configurations. Let it propagate when missing data is a bug — missing payment configuration, missing audit records, missing foreign key targets. Catching ORA-01403 and returning a default for data that must exist hides real bugs that cause incorrect business outcomes. The most expensive NO_DATA_FOUND bugs are the ones that are caught and silenced.

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

← PreviousBest Practices for Exception Handling in Oracle PL/SQLNext →ORA-01422: Exact Fetch Returned More Than Requested Number of Rows
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged