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
✦ Definition~90s read
What is ORA-01403 - Missing Config Row Halts 4-Hour Batch?
ORA-01403 is Oracle's 'no data found' exception, raised when a SELECT INTO statement returns zero rows. In PL/SQL, SELECT INTO is a single-row fetch that expects exactly one row; if the query matches nothing, Oracle throws this exception rather than returning a NULL or empty result.
★
ORA-01403 is Oracle saying: you asked for one row and I found zero.
This is a design choice: SELECT INTO is meant for cases where a missing row is a logical error, not a normal condition. The problem is that many batch jobs use SELECT INTO for configuration lookups, and when a config row is missing—say, a currency rate or a processing flag—the entire batch aborts, potentially wasting hours of work.
The exception propagates unhandled unless you explicitly catch it with EXCEPTION WHEN NO_DATA_FOUND or use alternatives like MAX(...) with NVL, COUNT(*) checks, or BULK COLLECT with cursor attributes. In production, this is a common cause of overnight batch failures, especially in ETL pipelines or financial processing where a single missing reference table row can halt a multi-hour run.
The fix isn't just adding exception handlers—it's deciding whether a missing row should abort the batch (propagate) or be treated as a default value (handle), which is a business semantics decision, not a coding one.
Plain-English First
ORA-01403 is Oracle saying: you asked for one row and I found zero. It happens when a SELECT INTO statement expects exactly one row but the query returns nothing. It is the single most common runtime error in PL/SQL production systems and the primary originating error behind the ORA-06512 stack traces that appear in every Oracle error log. Reading it correctly — and deciding whether to fix it with a default, an error, or a prevention pattern — is the core skill.
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 Without a Row Is a Batch Killer
ORA-01403 is Oracle's signal that a SELECT INTO statement returned zero rows. In Java batch processing, this exception halts the entire transaction unless caught. The core mechanic: Oracle's implicit cursor fetch expects exactly one row; zero rows raises NO_DATA_FOUND, not an empty result set.
In practice, this bites hardest when a configuration table lookup fails mid-batch. A single missing row — a currency code, a status mapping, a rate — stops a 4-hour job at minute 3. The exception propagates up the call stack, rolling back all completed work. No partial commit, no retry, just a full restart.
Use explicit cursor loops (FOR rec IN (SELECT ...)) or bulk collect with SAVE EXCEPTIONS to avoid this. For singleton lookups, wrap SELECT INTO in a try-catch for NO_DATA_FOUND and provide a default or log a warning. In high-throughput systems, a missing config row is a design smell — it should be validated at startup, not at runtime.
Not Just a Missing Row
ORA-01403 also fires on nested table operations and FORALL with no rows — it's not limited to SELECT INTO. Always check the exact operation context.
Production Insight
A missing exchange rate row in a currency conversion batch caused a 3-hour reprocess every Monday morning.
The batch failed at row 47 of 2 million, rolling back all 46 prior conversions.
Rule: validate all configuration dependencies before the batch starts — fail fast, not mid-stream.
Key Takeaway
SELECT INTO demands exactly one row — zero rows throws, not returns empty.
Always wrap singleton lookups in try-catch or switch to cursor loops for resilience.
Validate config tables at application startup to fail early, not during a 4-hour batch.
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
-- ============================================================-- SELECT INTO: the one-row contract-- Zero rows = ORA-01403, Multiple rows = ORA-01422-- Runnable on Oracle 19c, 21c, 23ai-- ============================================================-- Setup: create a test tableCREATETABLEdemo_accounts (
account_id NUMBERPRIMARYKEY,
account_type VARCHAR2(30) NOTNULL,
balance NUMBER(15,2) NOTNULL,
status VARCHAR2(10) DEFAULT'ACTIVE'
);
INSERTINTO demo_accounts VALUES (1, 'CHECKING', 5000.00, 'ACTIVE');
INSERTINTO demo_accounts VALUES (2, 'SAVINGS', 12000.00, 'ACTIVE');
INSERTINTO 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-01403SELECT balance INTO v_balance
FROM demo_accounts
WHERE account_id = 99;
DBMS_OUTPUT.PUT_LINE('Balance: ' || v_balance);
EXCEPTIONWHEN 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-01422SELECT balance INTO v_balance
FROM demo_accounts
WHERE account_type = 'CHECKING';
DBMS_OUTPUT.PUT_LINE('Balance: ' || v_balance);
EXCEPTIONWHEN 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 parameterBEGINSELECT 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);
EXCEPTIONWHEN 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;
The SELECT INTO Contract
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
-- ============================================================-- CASE 1: Missing data is a BUG — propagate with context-- Payment config MUST exist for every active account type-- ============================================================CREATEORREPLACEFUNCTIONget_payment_config(
p_account_type INVARCHAR2
) RETURN payment_config%ROWTYPEIS
v_config payment_config%ROWTYPE;
BEGINSELECT * INTO v_config
FROM payment_config
WHERE account_type = p_account_type
AND active_flag = 'Y';
RETURN v_config;
EXCEPTIONWHEN 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-- ============================================================CREATEORREPLACEFUNCTIONget_theme_preference(
p_user_id INNUMBER
) RETURNVARCHAR2IS
v_theme VARCHAR2(50);
BEGINSELECT theme INTO v_theme
FROM user_preferences
WHERE user_id = p_user_id;
RETURN v_theme;
EXCEPTIONWHEN NO_DATA_FOUND THEN-- New users have no preferences — return system default-- No error, no alert — this is normal behaviorRETURN'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-- ============================================================CREATEORREPLACEFUNCTIONget_account_balance_safe(
p_account_id INNUMBER
) RETURNNUMBERIS
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 valueSELECTNVL(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 0END get_account_balance_safe;
/
-- ============================================================-- CASE 4: Existence check using EXISTS subquery-- Most efficient when you only need a boolean answer-- ============================================================CREATEORREPLACEFUNCTIONhas_pending_order(
p_customer_id INNUMBER
) RETURNBOOLEANIS
v_exists NUMBER;
BEGINSELECTCOUNT(*) INTO v_exists
FROM dual
WHEREEXISTS (
SELECT1FROM orders
WHERE customer_id = p_customer_id
AND status = 'PENDING'
);
RETURN v_exists = 1;
-- Never raises ORA-01403 — COUNT(*) FROM dual always returns one rowEND has_pending_order;
/
Propagate vs. Handle Decision Framework
Missing payment configuration = BUG — propagate with RAISE_APPLICATION_ERROR and halt the batch. Never return a default payment rule.
Missing user preference = VALID STATE — return 'system_default' and continue. No error, no alert.
Missing audit record = BUG — propagate immediately. Audit gaps are compliance violations.
Missing notification preference = VALID STATE — default to email notification. Let users opt out later.
Missing foreign key target = BUG — the referential integrity is broken. Propagate and investigate.
Rule: if a default value would cause incorrect business outcomes, propagate the error.
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
-- ============================================================-- Pattern 1: NVL(MAX(...)) — simplest, 3 lines, no exception-- Best for: single scalar values where zero rows means default-- ============================================================CREATEORREPLACEFUNCTIONget_balance_nvl_max(
p_account_id INNUMBER
) RETURNNUMBERIS
v_balance NUMBER;
BEGINSELECTNVL(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-01403END 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.-- ============================================================CREATEORREPLACEFUNCTIONget_account_bulk(
p_account_id INNUMBER
) RETURN demo_accounts%ROWTYPEISTYPE account_tab ISTABLEOF demo_accounts%ROWTYPE;
v_results account_tab;
v_empty demo_accounts%ROWTYPE; -- All fields NULLBEGINSELECT *
BULKCOLLECTINTO v_results
FROM demo_accounts
WHERE account_id = p_account_id;
-- Primary key lookup: at most one row, LIMIT not neededIF v_results.COUNT = 0THENRETURN v_empty; -- Return record with all fields NULLENDIF;
RETURNv_results(1);
-- Never raises ORA-01403 — empty collection insteadEND get_account_bulk;
/
-- BULK COLLECT with LIMIT — use when WHERE could match many rowsCREATEORREPLACEFUNCTIONget_first_active_account(
p_account_type INVARCHAR2
) RETURN demo_accounts%ROWTYPEISTYPE account_tab ISTABLEOF demo_accounts%ROWTYPE;
v_results account_tab;
v_empty demo_accounts%ROWTYPE;
BEGINSELECT *
BULKCOLLECTINTO v_results
FROM demo_accounts
WHERE account_type = p_account_type
AND status = 'ACTIVE'ORDERBY account_id
FETCHFIRST1ROWONLY; -- Oracle 12c+ row limiting clauseIF v_results.COUNT = 0THENRETURN v_empty;
ENDIF;
RETURNv_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-- ============================================================CREATEORREPLACEFUNCTIONget_account_cursor(
p_account_id INNUMBER
) RETURN demo_accounts%ROWTYPEISCURSOR c_account ISSELECT * FROM demo_accounts
WHERE account_id = p_account_id;
v_result demo_accounts%ROWTYPE;
BEGINOPEN c_account;
FETCH c_account INTO v_result;
IF c_account%NOTFOUNDTHENCLOSE c_account;
-- v_result has all fields NULL — return it or handle as neededRETURN v_result;
ENDIF;
CLOSE c_account;
RETURN v_result;
-- Never raises ORA-01403 — %NOTFOUND check insteadEND 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-- ============================================================CREATEORREPLACEPROCEDUREprocess_account_with_check(
p_account_id INNUMBER
) IS
v_count NUMBER;
v_balance NUMBER;
BEGIN-- First query: check existenceSELECTCOUNT(*) INTO v_count
FROM demo_accounts
WHERE account_id = p_account_id;
IF v_count = 0THEN
DBMS_OUTPUT.PUT_LINE('Account ' || p_account_id || ' does not exist');
RETURN;
ENDIF;
-- Second query: safe to use SELECT INTO — row guaranteed to exist-- Note: race condition possible if another session deletes between queriesSELECT 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-- ============================================================CREATEORREPLACEFUNCTIONaccount_exists(
p_account_id INNUMBER
) RETURNBOOLEANIS
v_exists NUMBER;
BEGINSELECTCOUNT(*) INTO v_exists
FROM dual
WHEREEXISTS (
SELECT1FROM 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 rowEND 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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
-- ============================================================-- Pre-flight validation: check all reference data before batch start-- ============================================================CREATEORREPLACEPACKAGE preflight_pkg AS-- Validate all active account types have payment configPROCEDURE validate_payment_config;
-- Generic: validate all FK values in source exist in targetPROCEDUREvalidate_foreign_keys(
p_source_table INVARCHAR2,
p_source_column INVARCHAR2,
p_target_table INVARCHAR2,
p_target_column INVARCHAR2
);
END preflight_pkg;
/
CREATEORREPLACEPACKAGEBODY preflight_pkg ASPROCEDURE validate_payment_config IS
v_missing_types VARCHAR2(4000);
BEGIN-- Find account types that exist in accounts but not in payment_configSELECTLISTAGG(DISTINCT a.account_type, ', ')
WITHINGROUP (ORDERBY a.account_type)
INTO v_missing_types
FROM accounts a
LEFTJOIN payment_config pc
ON pc.account_type = a.account_type
AND pc.active_flag = 'Y'WHERE pc.account_type ISNULLAND a.status = 'ACTIVE';
IF v_missing_types ISNOTNULLTHEN
RAISE_APPLICATION_ERROR(
-20010,
'Pre-flight failed: missing payment config for account types: '
|| v_missing_types
|| ' | Insert config rows before running the batch.'
);
ENDIF;
DBMS_OUTPUT.PUT_LINE('Pre-flight: payment config validated — all types present');
END validate_payment_config;
PROCEDUREvalidate_foreign_keys(
p_source_table INVARCHAR2,
p_source_column INVARCHAR2,
p_target_table INVARCHAR2,
p_target_column INVARCHAR2
) 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 := '
SELECTCOUNT(DISTINCT s.' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_source_column) || ')
FROM' || DBMS_ASSERT.SQL_OBJECT_NAME(p_source_table) || ' s
WHERENOTEXISTS (
SELECT1FROM' || 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) || '
)';
EXECUTEIMMEDIATE v_sql INTO v_missing_count;
IF v_missing_count > 0THEN
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
);
ENDIF;
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;EXCEPTIONWHENOTHERSTHEN
DBMS_OUTPUT.PUT_LINE('Pre-flight failed: ' || SQLERRM);
-- Log and alert — do not start the batchRAISE;
END;
/
Pre-Flight vs. Runtime Error Detection
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
-- ============================================================-- Skip-and-continue batch processing pattern-- Individual record failures are logged; batch continues-- ============================================================CREATEORREPLACEPACKAGE batch_processor_pkg ASPROCEDUREprocess_payment_batch(
p_batch_date INDATE,
p_processed_count OUTNUMBER,
p_error_count OUTNUMBER
);
END batch_processor_pkg;
/
CREATEORREPLACEPACKAGEBODY batch_processor_pkg AS-- Autonomous transaction logging — survives rollback of the recordPROCEDURElog_batch_error(
p_batch_date INDATE,
p_account_id INNUMBER,
p_error_code INNUMBER,
p_error_msg INVARCHAR2,
p_error_stack INVARCHAR2
) ISPRAGMA AUTONOMOUS_TRANSACTION;
BEGININSERTINTObatch_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 transactionEND log_batch_error;
PROCEDUREprocess_payment_batch(
p_batch_date INDATE,
p_processed_count OUTNUMBER,
p_error_count OUTNUMBER
) ISCURSOR c_accounts ISSELECT account_id, account_type, balance
FROM accounts
WHERE status = 'ACTIVE'ORDERBY account_id;
v_config payment_config%ROWTYPE;
v_fee NUMBER;
BEGIN
p_processed_count := 0;
p_error_count := 0;
FOR rec IN c_accounts LOOPBEGIN-- This SELECT INTO may raise ORA-01403 for new account typesSELECT * 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 = SYSTIMESTAMPWHERE 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 recordsIFMOD(p_processed_count, 1000) = 0THENCOMMIT;
ENDIF;
EXCEPTIONWHENOTHERSTHEN-- 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 preservedEND;
ENDLOOP;
-- Final commit for remaining recordsCOMMIT;
-- Summary output
DBMS_OUTPUT.PUT_LINE(
'Batch complete: ' || p_processed_count || ' processed, '
|| p_error_count || ' errors'
);
-- Alert if error rate exceeds thresholdIF p_error_count > 0AND p_processed_count > 0THENIF (p_error_count / (p_processed_count + p_error_count)) > 0.05THEN-- 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.'
);
ENDIF;
ENDIF;
END process_payment_batch;
END batch_processor_pkg;
/
-- ============================================================-- Batch error log table-- ============================================================CREATETABLEbatch_error_log (
log_id NUMBERGENERATEDALWAYSASIDENTITYPRIMARYKEY,
batch_date DATENOTNULL,
account_id NUMBER,
error_code NUMBER,
error_message VARCHAR2(4000),
error_stack VARCHAR2(4000),
created_at TIMESTAMPWITHTIMEZONEDEFAULTSYSTIMESTAMP
);
CREATEINDEX idx_batch_error_date ONbatch_error_log(batch_date DESC);
CREATEINDEX idx_batch_error_account ONbatch_error_log(account_id);
Skip-and-Continue Design Rules
Use WHEN OTHERS inside the record loop — not just WHEN NO_DATA_FOUND — because other exceptions may occur during processing
Log errors using PRAGMA AUTONOMOUS_TRANSACTION — without it, a rollback of the record also rolls back the error log entry
Capture FORMAT_ERROR_STACK immediately in the handler — before any other SQL overwrites the error context
Set a failure rate threshold — if more than 5% of records fail, something systematic is wrong and the batch should alert
Commit in batches (every 1000 records) — not per record and not at the end — to balance performance with recoverability
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
-- ============================================================-- Complete SELECT INTO with both failure modes handled-- ============================================================CREATEORREPLACEFUNCTIONget_account_details(
p_account_id INNUMBER
) RETURN demo_accounts%ROWTYPEIS
v_result demo_accounts%ROWTYPE;
BEGINSELECT * INTO v_result
FROM demo_accounts
WHERE account_id = p_account_id;
RETURN v_result;
EXCEPTIONWHEN 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,
CASEWHEN c.constraint_type ISNOTNULLTHEN'UNIQUE CONSTRAINT EXISTS'ELSE'NO UNIQUE CONSTRAINT — potential TOO_MANY_ROWS risk'ENDAS constraint_status
FROM (
-- Tables and columns used in SELECT INTO statements-- Update this list based on your codebase auditSELECT'DEMO_ACCOUNTS'AS table_name, 'ACCOUNT_ID'AS column_name FROM dual
UNIONALLSELECT'PAYMENT_CONFIG', 'ACCOUNT_TYPE'FROM dual
UNIONALLSELECT'USER_PREFERENCES', 'USER_ID'FROM dual
) t
LEFTJOIN (
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
ORDERBY 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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
-- ============================================================-- Test framework for ORA-01403 handlers-- Each test forces the exception and verifies handler behavior-- ============================================================-- Test 1: Default value handler returns correct defaultCREATEORREPLACEPROCEDURE 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')
);
ENDIF;
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 codeCREATEORREPLACEPROCEDURE test_missing_config_error ISBEGINBEGIN-- Account type that does not exist — forces NO_DATA_FOUNDDECLARE
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;
EXCEPTIONWHENOTHERSTHENIFSQLCODE = -20010THEN-- Correct custom error code from the handler
DBMS_OUTPUT.PUT_LINE('PASS: get_payment_config raises -20010 for missing config');
ELSIFSQLCODE = -20902THEN-- 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;
ENDIF;
END;
END test_missing_config_error;
/
-- Test 3: Batch skip-and-continue processes valid records and logs failuresCREATEORREPLACEPROCEDURE test_batch_skip_continue IS
v_processed NUMBER;
v_errors NUMBER;
BEGIN-- Setup: insert test accounts with one missing configINSERTINTOaccounts (account_id, account_type, balance, status)
VALUES (90001, 'CHECKING', 1000, 'ACTIVE');
INSERTINTOaccounts (account_id, account_type, balance, status)
VALUES (90002, 'MISSING_TYPE_XYZ', 2000, 'ACTIVE'); -- No config for this typeINSERTINTOaccounts (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 errorIF v_processed != 2THEN
RAISE_APPLICATION_ERROR(-20903, 'FAIL: expected 2 processed, got ' || v_processed);
ENDIF;
IF v_errors != 1THEN
RAISE_APPLICATION_ERROR(-20904, 'FAIL: expected 1 error, got ' || v_errors);
ENDIF;
-- Verify error was loggedDECLARE
v_log_count NUMBER;
BEGINSELECTCOUNT(*) INTO v_log_count
FROM batch_error_log
WHERE account_id = 90002AND batch_date = TRUNC(SYSDATE);
IF v_log_count = 0THEN
RAISE_APPLICATION_ERROR(-20905, 'FAIL: error not logged for account 90002');
ENDIF;
END;
DBMS_OUTPUT.PUT_LINE('PASS: batch skips failing record, processes valid ones, logs error');
-- CleanupDELETEFROM accounts WHERE account_id BETWEEN90001AND90003;
DELETEFROM batch_error_log WHERE account_id = 90002;
COMMIT;
EXCEPTIONWHENOTHERSTHEN-- Cleanup on failureDELETEFROM accounts WHERE account_id BETWEEN90001AND90003;
DELETEFROM 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.txtTEXT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
============================================================
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 RESTAPI: return404
logger.warn("Data not found", Map.of(
"oraCode", e.getErrorCode(),
"procedure", "get_payment_config",
"param", "PREMIUM_SAVINGS"
));
} elseif (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 {
// UnexpectedOracle 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'
}));
} elseif (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. MapORA-01403 to HTTP404 in RESTAPIs 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.
● Production incidentPOST-MORTEMseverity: high
Payment Processing Halted for 4 Hours Due to Missing Configuration Row
Symptom
The 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.
Assumption
The 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 cause
A 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.
Fix
Three 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 processing
SELECT INTO without a NO_DATA_FOUND handler is a time bomb in any procedure that queries a lookup table
Batch jobs must implement skip-and-continue error handling — halting the entire batch for a single missing row wastes all preceding work
Deployments 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 resolution6 entries
Symptom · 01
ORA-01403 with no preceding context in the error stack
→
Fix
The 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.
Symptom · 02
ORA-01403 appears with ORA-06512 entries deep in the call stack
→
Fix
Read 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.
Symptom · 03
ORA-01403 occurs intermittently — the same input works sometimes and fails other times
→
Fix
The 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.
Symptom · 04
ORA-01403 appears after a deployment or data migration
→
Fix
New 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.
Symptom · 05
ORA-01403 masked by WHEN OTHERS handler — error log shows a different error or no error
→
Fix
A 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.
Symptom · 06
ORA-01403 in a procedure that worked before a schema change
→
Fix
A 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 Quick Debug Cheat SheetFast 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 action
Query 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 now
If 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 action
Map 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 now
Identify 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 action
Audit 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 now
Compare 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 action
Compare 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 now
If 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'.
SELECT INTO Alternatives for Single-Row Lookups
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
1
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
2
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
3
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
4
Pre-flight validation catches missing reference data at batch startup in seconds
not at record 500,001 after hours of processing
5
Skip-and-continue batch processing isolates failing records without halting
log with autonomous transactions and report all failures at the end
6
Handle both NO_DATA_FOUND and TOO_MANY_ROWS for every SELECT INTO unless the unique constraint is verified and enforced
7
Test every exception handler by forcing the exception
untested handlers fail in production when they are needed most
Common mistakes to avoid
6 patterns
×
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 PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What causes ORA-01403 and how do you handle it in PL/SQL?
Q02SENIOR
When should you let ORA-01403 propagate versus catching it?
Q03SENIOR
What alternatives to SELECT INTO never raise ORA-01403?
Q04SENIOR
How do you prevent ORA-01403 from halting a batch job?
Q05SENIOR
How does ORA-01403 relate to ORA-06512 in an error stack?
Q01 of 05JUNIOR
What causes ORA-01403 and how do you handle it in PL/SQL?
ANSWER
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?
Q02 of 05SENIOR
When should you let ORA-01403 propagate versus catching it?
ANSWER
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.
Q03 of 05SENIOR
What alternatives to SELECT INTO never raise ORA-01403?
ANSWER
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.
Q04 of 05SENIOR
How do you prevent ORA-01403 from halting a batch job?
ANSWER
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.
Q05 of 05SENIOR
How does ORA-01403 relate to ORA-06512 in an error stack?
ANSWER
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.
01
What causes ORA-01403 and how do you handle it in PL/SQL?
JUNIOR
02
When should you let ORA-01403 propagate versus catching it?
SENIOR
03
What alternatives to SELECT INTO never raise ORA-01403?
SENIOR
04
How do you prevent ORA-01403 from halting a batch job?
SENIOR
05
How does ORA-01403 relate to ORA-06512 in an error stack?
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.
Was this helpful?
04
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.
Was this helpful?
05
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).
Was this helpful?
06
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.