ORA-01403: No Data Found – Causes, Fixes & Prevention
- 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
- 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
Need to verify whether the row exists in the target table
SELECT COUNT(*) FROM target_table WHERE key_column = 'failing_value';SELECT * FROM target_table WHERE key_column = 'failing_value';Need to find the SELECT INTO statement in a package body
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;Need to find all SELECT INTO statements without NO_DATA_FOUND handlers in a package
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;Need to check if a view is filtering out rows that should be visible
SELECT COUNT(*) FROM base_table WHERE key_column = 'failing_value';SELECT COUNT(*) FROM view_name WHERE key_column = 'failing_value';Production Incident
Production Debug GuideFrom error message to root cause resolution
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: 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;
- 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
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.
-- ============================================================ -- 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; /
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.
-- ============================================================ -- 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; /
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.
-- ============================================================ -- 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; /
- 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
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.
-- ============================================================ -- 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);
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.
-- ============================================================ -- 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;
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 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; /
- 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
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.
============================================================ 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
| Pattern | Zero Rows Behavior | Multiple Rows Behavior | Code Complexity | Best Use Case |
|---|---|---|---|---|
| SELECT INTO | Raises ORA-01403 | Raises ORA-01422 | Low (3 lines) | Zero rows is a bug — you want the exception to propagate and alert |
| NVL(MAX(col)) | Returns NVL default value | Returns MAX of all rows | Low (3 lines) | Scalar default — simplest pattern that never raises ORA-01403 |
| BULK COLLECT | Returns empty collection | Returns all rows (or LIMIT N) | Medium (8 lines) | Full row retrieval where zero rows is valid |
| Explicit cursor FETCH | cursor%NOTFOUND = TRUE | Fetches first row only | High (10+ lines) | Complex per-row logic or multi-row sequential processing |
| COUNT(*) pre-check | Returns 0 — skip SELECT INTO | Returns count > 1 — handle duplicate | Medium (6 lines) | Caller needs to branch on existence before processing |
| EXISTS subquery | Returns FALSE | Returns 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
Interview Questions on This Topic
- QWhat causes ORA-01403 and how do you handle it in PL/SQL?JuniorReveal
- QWhen should you let ORA-01403 propagate versus catching it?Mid-levelReveal
- QWhat alternatives to SELECT INTO never raise ORA-01403?Mid-levelReveal
- QHow do you prevent ORA-01403 from halting a batch job?SeniorReveal
- QHow does ORA-01403 relate to ORA-06512 in an error stack?Mid-levelReveal
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.
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.