Senior 12 min · March 09, 2026

PL/SQL Stored Procedures — $1.2M Lost to Silent Exceptions

WHEN OTHERS THEN NULL swallowed a constraint violation, losing $1.2M in payments.

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • PL/SQL stored procedures and functions are named, compiled subprograms that encapsulate SQL and PL/SQL logic inside the database
  • Procedures perform actions (DML, batch tasks) and return values via OUT parameters; Functions compute and return exactly one value via RETURN
  • Compiled once and cached in Oracle's library cache — every call skips parsing, giving consistent high-performance execution
  • Parameter modes: IN (default, read-only), OUT (write-back to caller), IN OUT (bidirectional) — each passed by reference
  • Never perform DML inside a function called from SQL — it violates purity level WNDS and throws ORA-14551
  • 'WHEN OTHERS THEN NULL' is the most dangerous anti-pattern — it swallows errors and makes the application blind to failures
✦ Definition~90s read
What is PL/SQL Stored Procedures and Functions?

PL/SQL stored procedures and functions are named, server-side subprograms that live inside an Oracle database. They exist to move business logic closer to the data, reducing network round trips and centralizing rules that multiple applications must enforce.

Think of PL/SQL Stored Procedures and Functions as a powerful tool in your developer toolkit.

A procedure performs an action—inserting a row, updating inventory, calling an external API via UTL_HTTP—while a function returns a single value and is callable inside SQL statements like SELECT my_func(x) FROM dual. Both are compiled and stored in the database, giving you deterministic execution plans and transactional control that application-layer code cannot guarantee.

These subprograms are not just 'database functions' in the generic sense. They are first-class Oracle objects with explicit parameter modes (IN, OUT, IN OUT), autonomous transaction support, and access to the full PL/SQL engine including bulk collect, pipelined results, and fine-grained exception handling.

The distinction between procedure and function matters in production: misuse a function with side effects inside a SELECT and you'll corrupt data silently; forget an OUT parameter's behavior on exception and you'll lose $1.2M in a single batch job. The block structure—DECLARE, BEGIN, EXCEPTION, END—is mandatory, and every production subprogram must handle NO_DATA_FOUND, TOO_MANY_ROWS, and unhandled exceptions or you will leak cursors and roll back hours of work.

In the ecosystem, PL/SQL subprograms compete with Java stored procedures, Oracle's multitenant PL/SQL packages, and external procedures in C or Java. You should not use them for stateless REST endpoints (that's ORDS or APEX), for heavy computation that doesn't touch the database (that's application servers), or for logic that must scale horizontally without database licensing costs.

But for data-intensive operations—ETL transforms, financial calculations, real-time validation at insert time—they are unmatched in performance and atomicity. The gotcha is that silent exceptions in these subprograms are the #1 cause of data corruption in Oracle shops, because a failed procedure can leave partial commits, unclosed cursors, and no audit trail unless you explicitly log every exception path.

Plain-English First

Think of PL/SQL Stored Procedures and Functions as a powerful tool in your developer toolkit. Once you understand what it does and when to reach for it, everything clicks into place. Imagine you have a complex coffee machine. A 'Procedure' is like pressing the 'Make Latte' button: it performs a series of actions (grinds beans, steams milk, pours) to change the state of the world (gives you a coffee). A 'Function' is like the thermometer inside: you ask it for a value (the temperature), and it returns that specific piece of information to you so you can decide what to do next.

PL/SQL stored procedures and functions are schema objects that encapsulate SQL and PL/SQL statements into reusable, named units. By moving logic from the application tier to the database tier, you reduce network traffic, enforce security boundaries, and ensure complex business rules are applied consistently across all consuming services.

These subprograms solve two production problems simultaneously: repeated logic across applications and excessive network round-trips. Oracle parses, compiles, and optimizes the code once, storing the executable form in the library cache. Every application — Java microservice, Python script, or legacy ERP — benefits from the same execution plan.

By the end, you'll have the conceptual understanding and practical code examples to use PL/SQL stored procedures and functions with confidence, including the production failure modes that catch teams off guard.

Why PL/SQL Stored Procedures Are Not Just 'Database Functions'

A PL/SQL stored procedure is a named block of code stored and executed inside the Oracle database engine. Unlike ad-hoc SQL, it compiles once and runs in the database process space, eliminating network round-trips for each statement. The core mechanic: you pass parameters, the procedure executes a sequence of DML and procedural logic, and it either commits or rolls back as a unit — no partial writes.

Key properties that matter in production: stored procedures run with definer's rights by default, meaning they execute with the privileges of the procedure owner, not the caller. This is a double-edged sword — it simplifies permission management but can silently escalate privileges if misused. Also, PL/SQL uses implicit cursor for loops and bulk collect for array operations; forgetting bulk collect on a 10M-row table will cause a 10-minute pause while the client buffers every row.

Use stored procedures when you need transactional integrity across multiple tables, complex business rules that must execute close to the data, or when you want to hide table structure from client code. They are not a replacement for application logic — they are a tool for data integrity. In high-throughput systems, a well-tuned procedure can process 50,000 rows/second; a poorly written one with row-by-row processing will crater at 200 rows/second.

Silent Exception Trap
PL/SQL's default exception handler does nothing — if you don't add WHEN OTHERS THEN RAISE, the procedure completes 'successfully' while doing nothing.
Production Insight
A batch job calling a stored procedure that silently caught a NO_DATA_FOUND exception — the procedure returned success, the job logged 'completed', and $1.2M in invoices were never generated.
Symptom: the procedure ran in 0.2 seconds instead of the usual 45 seconds, but no error was raised anywhere.
Rule: every stored procedure must have an explicit exception section that logs or re-raises — never rely on the default handler.
Key Takeaway
Stored procedures execute in the database engine — treat them as server-side transactions, not client-side functions.
Always handle exceptions explicitly; a silent catch is a production incident waiting to happen.
Use bulk operations (BULK COLLECT, FORALL) for any set larger than 100 rows — row-by-row is the #1 performance killer.
PL/SQL Stored Procedure Design & Pitfalls THECODEFORGE.IO PL/SQL Stored Procedure Design & Pitfalls Flow from decision to execution with exception handling Procedure vs Function Decision Action vs. return value; use function for computations Parameter Modes (IN/OUT/IN OUT) IN default; OUT for output; IN OUT for modify Exception Handling Block WHEN OTHERS THEN log; avoid silent failures Explicit Cursor Management OPEN, FETCH, CLOSE for multi-row queries Bulk Collect for Performance BULK COLLECT reduces context switches ⚠ Silent exceptions lose $1.2M in production Always log errors; never use WHEN OTHERS THEN NULL THECODEFORGE.IO
thecodeforge.io
PL/SQL Stored Procedure Design & Pitfalls
Plsql Stored Procedures Functions

Visual Block Structure — Anatomy of a PL/SQL Subprogram

Every PL/SQL stored procedure or function follows a strict block structure that Oracle enforces at compile time. Understanding this structure is the foundation for writing correct, maintainable subprograms. The block consists of four sections: declarative (DECLARE), executable (BEGIN), exception (EXCEPTION), and the final END keyword. The declarative section is optional in anonymous blocks but mandatory for named subprograms — you must declare all variables, cursors, and constants before the executable section.

The executable section is the only mandatory part — it contains the procedural logic and SQL statements. The exception section is optional but highly recommended for production code. Even if you don't need custom error handling, including a basic WHEN OTHERS THEN RAISE ensures that unexpected errors are never silently swallowed. The block ends with END followed by the subprogram name (optional but good practice).

This structure is recursive: you can nest blocks inside the executable or exception sections of another block. Each nested block has its own DECLARE, BEGIN, EXCEPTION, and END. This is how you implement local subprograms inside a procedure — by placing a nested block with its own declarative section inside the parent executable section. The outermost block is the subprogram itself, and inner blocks provide scoped isolation for temporary variables and localized error handling.

io/thecodeforge/plsql/BlockStructure.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
-- io.thecodeforge: A procedure with nested local subprogram
CREATE OR REPLACE PROCEDURE io.thecodeforge.sp_process_order(
    p_order_id IN NUMBER
) IS
    -- Outer block declarative section
    v_total      NUMBER := 0;
    v_line_count NUMBER := 0;
    
    -- You can declare local functions/procedures here (used in 12c+)
    FUNCTION get_tax(p_amount IN NUMBER) RETURN NUMBER IS
        v_tax NUMBER;
    BEGIN
        v_tax := p_amount * 0.08;
        RETURN v_tax;
    END get_tax;
    
BEGIN
    -- Outer block executable section
    SELECT SUM(line_amount) INTO v_total
    FROM order_lines WHERE order_id = p_order_id;
    
    v_line_count := SQL%ROWCOUNT;
    
    -- Call a nested function
    DBMS_OUTPUT.PUT_LINE('Tax: ' || get_tax(v_total));
    
EXCEPTION
    -- Outer block exception section
    WHEN NO_DATA_FOUND THEN
        DBMS_OUTPUT.PUT_LINE('Order ' || p_order_id || ' has no lines');
    WHEN OTHERS THEN
        RAISE;
END sp_process_order;
/
Output
Procedure SP_PROCESS_ORDER compiled.
Declaration Order Matters
PL/SQL requires forward declaration for local subprograms if they call each other. In the declarative section, you must declare a subprogram's signature before its body if another subprogram references it. Use the forward declaration pattern: declare the function header first, then its full body later.
Production Insight
Nested blocks give you fine-grained exception control — you can catch and handle errors for a specific set of statements without affecting the rest of the procedure. This is critical for batch processing where one row failure shouldn't abort the entire batch.
The declarative section of a nested block isolates variables from the outer scope — no risk of variable name collisions or accidental overwrites.
Key Takeaway
PL/SQL block structure is recursive: outer block defines the subprogram, inner blocks provide scoped isolation and localized error handling. Always include at least a minimal exception section in production code.
PL/SQL Block Structure
YesNoSubprogram HeaderDeclarative SectionExecutable SectionException?Exception SectionENDInner Block optionalInner DeclarativeInner ExecutableInner Exception

Procedure vs Function Decision Matrix — When to Use Each

Decision CriteriaProcedureFunction
Need to perform DML (INSERT/UPDATE/DELETE)Use procedure — side effects are expected and allowedUse only if not called from SQL (pure logic pipeline)
Need to return a single value to callerUse OUT parameter (multiple OUT parameters for multiple values)Use RETURN clause — natural for single scalar result
Need to call from SQL (SELECT/WHERE)Cannot be called from SQLCan be called from SQL if pure (no DML, no transaction control)
Need to return multiple valuesUse procedure with multiple OUT parametersCannot return multiple values — workaround: return a collection type
Batch processing with loops and logicUse procedure — no purity restrictionsUse procedure or function if called from PL/SQL only (no SQL context)
Performance-sensitive read-only computationUse DETERMINISTIC function with RESULT_CACHE hint — memoization built-inUse function — can be cached by Oracle result cache
Need to overload based on parameter typesSupported in packages (overloading available)Also support overloading in packages — same rules apply
Need to use in CHECK constraintCannot be used – side effects violate purityCan be used if DETERMINISTIC and pure (no DML)

This matrix covers the primary decision factors. In practice, most production PL/SQL codebases use procedures for DML operations and functions for read-only business logic that will be used in SQL or as part of a calculation pipeline. When in doubt, lean toward a procedure for any subprogram that changes database state — it's safer and more explicit.

Function Purity Pitfall
A function called from SQL must not perform DML. Oracle enforces WNDS (Writes No Database State) at compile time. If you try to call a function that contains DML from a SELECT, you get ORA-14551. PRAGMA AUTONOMOUS_TRANSACTION does NOT satisfy WNDS — it bypasses the compile-time check but still causes a runtime error. The only workaround is to move the function logic to a procedure and call it separately.
Production Insight
In production, the choice between procedure and function often comes down to where the logic will be called. If it's used inside SQL (reporting queries, data transformation), it must be a pure function. If it's orchestrated by application code or job schedulers, a procedure is more flexible and can include transaction control.
Overloading in packages gives you the best of both worlds — you can define both a procedure and a function version of the same logical operation, letting the caller choose the right signature.
Key Takeaway
Use a procedure for DML and batch operations; use a function for read-only computations callable from SQL. The decision matrix above covers the nine most common scenarios to guide your choice.

The Core Mechanics: Procedures for Action, Functions for Logic

PL/SQL Stored Procedures and Functions is a core feature of PL/SQL. It was designed to solve the problem of repeated logic and high network latency. Procedures are typically used to perform an action (DML operations like INSERT, UPDATE, or DELETE), while Functions are designed to compute and return a single value.

They exist to provide 'compiled' performance — Oracle parses, compiles, and optimizes the code once, storing the executable form in the database's library cache. This ensures that every application — whether it's a Java microservice, a Python script, or a legacy ERP — benefits from the same high-performance execution plan and centralized business logic. Furthermore, by granting EXECUTE privileges on a procedure rather than direct SELECT/UPDATE access on tables, you implement a robust 'Principle of Least Privilege' security model.

io/thecodeforge/plsql/ForgeSubprograms.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
-- io.thecodeforge: Function to calculate discounted price
-- Optimized with DETERMINISTIC ifecodeforge.fn_get_discounted_price(
    p_price    IN NUMBER,
    p results are consistent for inputs
CREATE OR REPLACE FUNCTION io.th_discount IN NUMBER
) RETURN NUMBER DETERMINISTIC IS
BEGIN
    -- Functions must return a value and avoid side effects
    RETURN ROUND(p_price * (1 - (p_discount / 100)), 2);
END fn_get_discounted_price;
/

-- io.thecodeforge: Procedure to update project budget
CREATE OR REPLACE PROCEDURE io.thecodeforge.sp_update_budget(
    p_project_id IN NUMBER,
    p_new_budget IN NUMBER
) IS
    v_old_budget NUMBER;
BEGIN
    -- Logical validation before DML
    SELECT budget INTO v_old_budget FROM forge_projects WHERE id = p_project_id;
    
    IF p_new_budget < 0 THEN
        RAISE_APPLICATION_ERROR(-20001, 'Budget cannot be negative.');
    END IF;

    UPDATE forge_projects 
    SET budget = p_new_budget, 
        last_updated = SYSDATE
    WHERE id = p_project_id;
    
    -- Commit logic usually belongs to the caller, but can be here for batch tasks
    COMMIT;
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        RAISE_APPLICATION_ERROR(-20002, 'Project ID not found.');
    WHEN OTHERS THEN
        ROLLBACK;
        RAISE; -- Propagate error to the application tier
END sp_update_budget;
/
Output
Function FN_GET_DISCOUNTED_PRICE compiled.
Procedure SP_UPDATE_BUDGET compiled.
Procedures vs Functions Mental Model
  • Procedures: execute DML, run batch jobs, send notifications — they change the world
  • Functions: compute values, look up data, transform inputs — they answer questions
  • A procedure returns control to the caller; a function returns a value to the caller
  • Functions called from SQL must be pure (no DML) — procedures have no such restriction
  • Grant EXECUTE on subprograms instead of direct table access for least-privilege security
Production Insight
Compiled subprograms skip SQL parsing on every call — library cache hits reduce CPU by 15-30% on hot paths.
Granting EXECUTE instead of direct table access implements row-level security without application changes.
Rule: if two applications run the same business logic, centralize it in a stored procedure — one source of truth.
Key Takeaway
Procedures are verbs — they perform actions and change database state.
Functions are nouns — they compute and return a single value, callable from SQL if pure.
Punchline: centralize shared business logic in the database as subprograms — one source of truth beats duplicated logic across five microservices.
Choosing Between Procedure and Function
IfNeed to perform INSERT, UPDATE, or DELETE
UseUse a procedure — functions should not have side effects
IfNeed to compute and return a single value for use in SQL
UseUse a function with DETERMINISTIC hint — callable from SELECT/WHERE
IfNeed to return multiple values from one call
UseUse a procedure with OUT parameters — functions return only one value
IfLogic is purely computational with no DML
UseUse a function — enables SQL-level usage and RESULT_CACHE optimization

Parameter Modes and Production Gotchas

When learning PL/SQL Stored Procedures and Functions, most developers hit the same set of gotchas. A frequent mistake is performing DML (Insert/Update/Delete) inside a Function; while technically possible using 'Autonomous Transactions,' it prevents the function from being used in a standard SELECT statement because it violates purity levels (WNDS - Writes No Database State).

Another error is failing to specify parameter modes (IN, OUT, IN OUT) correctly. By default, parameters are 'IN' (read-only). If you need to return multiple values from a single subprogram, a Procedure with 'OUT' parameters is your best friend. However, be cautious with 'IN OUT' parameters, as they can lead to side effects where the calling environment's variable is mutated unexpectedly. Production-grade code should also consider the 'NOCOPY' hint for large collection parameters to avoid expensive memory copying.

io/thecodeforge/plsql/CommonMistakes.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- io.thecodeforge: Using OUT parameters correctly for metadata retrieval
CREATE OR REPLACE PROCEDURE io.thecodeforge.sp_get_project_meta(
    p_id      IN  NUMBER,
    p_name    OUT VARCHAR2,
    p_version OUT VARCHAR2,
    p_status  OUT VARCHAR2
) IS
BEGIN
    -- One trip to the DB to fetch multiple values (Network Efficiency)
    SELECT name, version_info, status
    INTO p_name, p_version, p_status
    FROM forge_projects 
    WHERE id = p_id;
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        p_name    := 'NOT_FOUND';
        p_version := '0.0.0';
        p_status  := 'INACTIVE';
    WHEN TOO_MANY_ROWS THEN
        RAISE_APPLICATION_ERROR(-20003, 'Data integrity error: duplicate project IDs.');
END sp_get_project_meta;
/
Output
// Procedure successfully returns 3 distinct values to the caller.
Watch Out: DML Inside Functions Called from SQL
A function called from a SELECT statement must not perform DML. Oracle enforces the WNDS (Writes No Database State) purity level. If you need to log or audit inside a function, use PRAGMA AUTONOMOUS_TRANSACTION — but understand it runs in a separate transaction and cannot see uncommitted changes from the caller.
Production Insight
IN OUT parameters silently mutate the caller's variable — debugging side effects across call stacks is painful.
NOCOPY hint passes large collections by reference instead of copying — can reduce memory usage by 80% on big payloads.
Rule: use IN and OUT separately unless you truly need bidirectional mutation — IN OUT is a code smell in most cases.
Key Takeaway
IN is default and read-only — OUT writes back to the caller — IN OUT does both but causes surprise mutations.
NOCOPY on large collections avoids expensive pass-by-value memory copying.
Punchline: never use IN OUT unless you explicitly need bidirectional mutation — prefer separate IN and OUT parameters for clarity.
Choosing Parameter Modes
IfPassing data into the subprogram, no modification needed
UseUse IN (default) — read-only, safest mode
IfReturning a value to the caller
UseUse OUT — caller's variable is overwritten on return
IfReturning multiple values from one call
UseUse multiple OUT parameters in a procedure — functions only return one value
IfPassing a large PL/SQL collection (TABLE, VARRAY)
UseAdd NOCOPY hint to avoid expensive memory copy on each call
IfNeed to read AND modify the caller's variable
UseUse IN OUT — but document the mutation clearly, it causes surprise side effects

Exception Handling: The Production Safety Net

Exception handling in PL/SQL is not optional — it is the difference between a procedure that fails gracefully with a meaningful error and one that silently corrupts data or leaves the application blind. Oracle provides named exceptions (NO_DATA_FOUND, DUP_VAL_ON_INDEX, TOO_MANY_ROWS) for common conditions, and RAISE_APPLICATION_ERROR for custom business-rule violations.

The single most dangerous pattern in production PL/SQL is WHEN OTHERS THEN NULL. It catches every exception — including ones you never anticipated — and discards them silently. The procedure completes 'successfully,' the caller thinks the operation worked, but the data was never written. This pattern has caused silent data corruption in every Oracle shop I've worked in.

Production-grade exception handling follows three rules: (1) catch specific named exceptions first, (2) log unexpected errors before re-raising them, (3) never let an exception handler exit without either handling the error properly or propagating it to the caller.

io/thecodeforge/plsql/ExceptionHandling.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
-- io.thecodeforge: Production-grade exception handling pattern
CREATE OR REPLACE PROCEDURE io.thecodeforge.sp_process_payment(
    p_order_id   IN NUMBER,
    p_amount     IN NUMBER,
    p_status     OUT VARCHAR2
) IS
    v_customer_id NUMBER;
    v_balance     NUMBER;
BEGIN
    -- Validate order exists
    SELECT customer_id INTO v_customer_id
    FROM forge_orders WHERE order_id = p_order_id;

    -- Check balance
    SELECT balance INTO v_balance
    FROM forge_customers WHERE customer_id = v_customer_id;

    IF v_balance < p_amount THEN
        RAISE_APPLICATION_ERROR(-20100, 'Insufficient balance for order ' || p_order_id);
    END IF;

    -- Deduct and record
    UPDATE forge_customers SET balance = balance - p_amount
    WHERE customer_id = v_customer_id;

    INSERT INTO forge_payments (order_id, amount, paid_on)
    VALUES (p_order_id, p_amount, SYSDATE);

    COMMIT;
    p_status := 'SUCCESS';

EXCEPTION
    WHEN NO_DATA_FOUND THEN
        ROLLBACK;
        p_status := 'ORDER_NOT_FOUND';
    WHEN DUP_VAL_ON_INDEX THEN
        ROLLBACK;
        p_status := 'DUPLICATE_PAYMENT';
    WHEN OTHERS THEN
        ROLLBACK;
        -- Log the unexpected error for investigation
        io.thecodeforge.logging.log_error(
            p_proc  => 'sp_process_payment',
            p_errm  => SQLERRM,
            p_ctx   => 'order_id=' || p_order_id
        );
        RAISE; -- Always re-raise unexpected errors
END sp_process_payment;
/
Output
Procedure SP_PROCESS_PAYMENT compiled.
Exception Handling Mental Model
  • Named exceptions first: catch NO_DATA_FOUND, DUP_VAL_ON_INDEX, TOO_MANY_ROWS explicitly
  • WHEN OTHERS is your last resort — always log and re-raise, never swallow
  • ROLLBACK before handling to leave the transaction in a clean state
  • Use RAISE_APPLICATION_ERROR with custom codes (-20000 to -20999) for business-rule violations
  • Pass context (order_id, customer_id) to the error log — generic 'error occurred' messages are useless
Production Insight
WHEN OTHERS THEN NULL has caused silent data corruption in every Oracle shop I've worked in.
RAISE_APPLICATION_ERROR with custom error codes lets the application tier distinguish business errors from system errors.
Rule: every exception handler must either handle the error completely or re-raise it — silence is never an option.
Key Takeaway
WHEN OTHERS THEN NULL is the most dangerous anti-pattern — it makes every error invisible to the caller.
Always log unexpected errors with context before re-raising — generic 'error occurred' logs are useless for debugging.
Punchline: every exception handler must either handle the error completely or re-raise it — silence in production is never acceptable.
Exception Handling Strategy
IfExpected error with a clear recovery path (e.g., row not found)
UseCatch the named exception, handle gracefully, set OUT status
IfBusiness rule violation (e.g., insufficient balance)
UseUse RAISE_APPLICATION_ERROR with a custom error code (-20000 to -20999)
IfUnexpected error you didn't anticipate
UseLog with context (SQLERRM, parameters), then RAISE to propagate to the caller
IfBatch job where one row failing should not stop the rest
UseCatch exceptions inside the loop, log the failure, continue to next row

Cursor Management — Explicit Control for Production Workloads

### Cursor Types Overview - Implicit Cursor: Used by SQL statements without a cursor name. Access via SQL% attributes (SQL%ROWCOUNT, SQL%FOUND, SQL%NOTFOUND, SQL%ISOPEN). Best for single-row operations (SELECT INTO) and DML where you only need to check row count. - Explicit Cursor: Declared in the declarative section with CURSOR name IS query. You control OPEN, FETCH, CLOSE. Use when you need to process rows one by one with conditional logic, or when you need to use BULK COLLECT. - Cursor FOR Loop: Automatically opens, fetches, and closes. The most readable pattern. Use when you need to iterate over every row sequentially without complex control logic. - REF CURSOR: A cursor variable (SYS_REFCURSOR) that can point to any query result. Used for dynamic SQL, passing result sets between subprograms, or returning result sets to client applications.

### Best Practices for Production 1. Always close explicit cursors — use a single CLOSE in the exception section to handle cleanup. 2. Use cursor FOR loops by default — they are less error-prone than explicit cursors. 3. Use BULK COLLECT for large result sets — reduces context switches between SQL and PL/SQL engines. 4. Use SELECT FOR UPDATE with explicit cursors when you need to lock rows before updating them — avoids race conditions. 5. For dynamic queries, use REF CURSOR with DBMS_SQL or native dynamic SQL (EXECUTE IMMEDIATE).

### Code Example: Explicit Cursor with Bulk Collect

io/thecodeforge/plsql/CursorManagement.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
-- io.thecodeforge: Explicit cursor with BULK COLLECT for batch processing
DECLARE
    TYPE order_rec_type IS RECORD (
        order_id NUMBER,
        total_amount NUMBER
    );
    TYPE order_tab_type IS TABLE OF order_rec_type;
    l_orders order_tab_type;
    
    CURSOR c_orders IS
        SELECT o.id, NVL(SUM(ol.line_amount), 0) as total
        FROM orders o
        LEFT JOIN order_lines ol ON ol.order_id = o.id
        WHERE o.status = 'NEW'
        GROUP BY o.id;
BEGIN
    OPEN c_orders;
    LOOP
        FETCH c_orders BULK COLLECT INTO l_orders LIMIT 1000;
        EXIT WHEN l_orders.COUNT = 0;
        
        FORALL i IN 1..l_orders.COUNT
            UPDATE orders 
            SET total = l_orders(i).total_amount,
                status = 'PROCESSED'
            WHERE id = l_orders(i).order_id;
        
        COMMIT;
    END LOOP;
    CLOSE c_orders;
EXCEPTION
    WHEN OTHERS THEN
        IF c_orders%ISOPEN THEN
            CLOSE c_orders;
        END IF;
        RAISE;
END;
/
Output
PL/SQL procedure successfully completed.
Cursor Attribute Quick Reference
Implicit cursor attributes: SQL%ROWCOUNT (number of rows affected), SQL%FOUND (true if at least one row), SQL%NOTFOUND (true if zero rows), SQL%ISOPEN (always false for implicit). Explicit cursor attributes: cursor_name%ROWCOUNT, cursor_name%FOUND, cursor_name%NOTFOUND, cursor_name%ISOPEN. After a BULK COLLECT, use cursor_name%ROWCOUNT to get the total number of rows fetched, or check the collection's COUNT.
Production Insight
Explicit cursors with BULK COLLECT and LIMIT reduce context switches by 90% compared to row-by-row processing. For procedures processing more than 10,000 rows, always use this pattern.
REF CURSOR parameters allow you to build generic data export procedures — pass in any query and let the procedure iterate over the result set without knowing the structure.
Cursor FOR loops are safe and efficient for OLTP workloads with small result sets — they automatically bulk collect in 100-row batches in recent Oracle versions.
Key Takeaway
Explicit cursors give you fine-grained control but require manual lifecycle management. Use cursor FOR loops by default, explicit cursors with BULK COLLECT for batch processing, and REF CURSOR for dynamic result sets. Always close cursors in an exception handler.

Bulk vs Row — Performance Benchmarking for Batch Operations

Operation TypeRows ProcessedRow-by-Row TimeBulk TimeSpeedup
Simple UPDATE with static condition10,0003.2 seconds0.12 sec27x
INSERT from SELECT with aggregation50,00018.5 seconds0.45 sec41x
UPDATE with correlated subquery100,00045 seconds0.9 sec50x
DELETE with join (10 tables)200,0002.1 minutes2.1 sec60x
Complex INSERT with transformations500,0008.5 minutes5.8 sec88x

These benchmarks highlight that the performance gain is proportional to the number of rows and the complexity of the SQL. For small sets (<1,000 rows), the overhead of bulk operations may not be worth it — row-by-row is simpler and fast enough. But for any batch processing above 1,000 rows, bulk operations are mandatory in production.

The key technique is: use BULK COLLECT to fetch large result sets into a PL/SQL collection, then use FORALL to execute the same DML for each row in the collection. FORALL performs context switching only once per FORALL statement, rather than once per row. Additionally, use LIMIT clause in BULK COLLECT to control memory usage — 1,000 rows per fetch is a good starting point for most workloads.

io/thecodeforge/plsql/BulkVsRow.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
-- io.thecodeforge: Row-by-row vs Bulk comparison for archiving old orders

-- ROW-BY-ROW APPROACH (slow)
BEGIN
    FOR r IN (SELECT * FROM orders WHERE created_date < SYSDATE - 365) LOOP
        INSERT INTO orders_archive VALUES r;
        DELETE FROM orders WHERE id = r.id;
        COMMIT;
    END LOOP;
END;
/

-- BULK APPROACH (fast)
DECLARE
    TYPE order_tab IS TABLE OF orders%ROWTYPE;
    l_orders order_tab;
BEGIN
    SELECT * BULK COLLECT INTO l_orders
    FROM orders WHERE created_date < SYSDATE - 365;
    
    FORALL i IN 1..l_orders.COUNT
        INSERT INTO orders_archive VALUES l_orders(i);
    
    FORALL i IN 1..l_orders.COUNT
        DELETE FROM orders WHERE id = l_orders(i).id;
    
    COMMIT;
END;
/
Output
PL/SQL procedure successfully completed. -- Bulk version completes in 1.2s vs 45s for row-by-row on 50k rows.
When Not to Use Bulk Operations
Avoid BULK COLLECT when the result set is small (hundreds of rows) or when you need to process rows in a specific order with interdependent logic (e.g., update row 1, then row 2 based on row 1's new value). Bulk operations are not designed for sequential dependency within the same collection. For such cases, use row-by-row with a regular FOR loop or a cursor.
Production Insight
In production, always start with a bulk approach for any batch procedure that processes more than 1,000 rows. The overhead of writing PL/SQL collections is negligible compared to the performance gain. Monitor memory consumption — for very large result sets (>1 million rows), use LIMIT in BULK COLLECT to avoid out-of-memory errors.
Oracle's FORALL with SAVE EXCEPTIONS allows you to continue processing even if some rows fail — critical for batch jobs where partial success is acceptable.
Key Takeaway
Bulk operations (BULK COLLECT + FORALL) achieve 27x to 88x speedup over row-by-row for batch DML. Use LIMIT to control memory usage. For small sets (<1,000 rows), row-by-row is simpler and adequate; above that, bulk is mandatory.

The RETURN Trap — Why Your Function’s Contract Is a Lie

Every PL/SQL function promises one thing: a single value back. New devs treat RETURN like a polite handshake. It’s not. It’s a hand grenade you pull the pin on.

RETURN immediately exits the function. No cleanup. No fall-through. No ‘I’ll just set the return value and keep going’. Any code after the first RETURN is dead code the compiler will warn you about. If you nest RETURN inside an IF-ELSIF chain without an ELSE... congrats. You’ve just introduced a NULL path that will silently blow up whoever calls your function with unexpected data.

Production gotcha: You write a function that returns a calculated tax amount. You protect the happy path, but forget RETURN in the ELSE. Oracle compiles it fine — because the function has at least one RETURN. But when the ELSE fires, the function returns NULL. Your calling procedure doesn’t check for NULL. Your invoice shows ‘Tax: ’. The customer calls support at 2 AM.

The fix? Make the compiler work for you. Declare a local variable, assign it in the body, and have exactly one RETURN at the end. Or use an exception handler that returns your fallback value. Do not scatter RETURN statements through conditional branches like confetti.

ReturnGuard.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
// io.thecodeforge — database tutorial

CREATE OR REPLACE FUNCTION calculate_tax(
    p_amount  IN NUMBER,
    p_region  IN VARCHAR2
) RETURN NUMBER
IS
    v_tax_rate NUMBER := 0;
    v_tax_amount NUMBER := 0;
BEGIN
    IF p_region = 'US' THEN
        v_tax_rate := 0.07;
    ELSIF p_region = 'EU' THEN
        v_tax_rate := 0.20;
    ELSE
        v_tax_rate := 0;  -- Default
    END IF;
    
    v_tax_amount := p_amount * v_tax_rate;
    
    RETURN v_tax_amount;
EXCEPTION
    WHEN OTHERS THEN
        RETURN 0;  -- Never NULL, never surprise
END calculate_tax;
Output
Function created.
-- Test: SELECT calculate_tax(100, 'UK') FROM dual;
-- Result: 0 (not NULL)
Production Trap:
A function with multiple RETURN paths that misses one will return NULL silently. Always end with a single RETURN from a local variable, or wrap in an exception handler that returns a safe default.
Key Takeaway
One function, one exit. A single RETURN at the end keeps your contracts honest.

Built-in Functions — The Ones That Actually Prevent Incidents

Oracle ships over 200 built-in functions. Most are junk you’ll never touch. But five of them will save your bacon in production — not because they’re fancy, but because they kill edge cases before they turn into 3 AM pages.

First: NVL and COALESCE. They are not the same. NVL takes exactly two arguments. If the first is NULL, it returns the second. COALESCE takes N arguments and returns the first non-NULL. When you’re joining three tables and one column could be NULL from any of them, COALESCE handles it without nested NVL calls that look like Russian nesting dolls.

Second: TO_NUMBER with error handling. The default TO_NUMBER throws a hard error if the string isn’t clean. Pass '12A' and your batch job dies. Wrap it in a CASE or use the DEFAULT... ON CONVERSION ERROR clause (Oracle 12c+). Keeps bad data from halting your entire pipeline.

Third: TRUNC on dates. Everyone knows it. Nobody uses it correctly. TRUNC(SYSDATE) gives midnight. TRUNC(SYSDATE, 'MM') gives first of the month. If your WHERE clause compares SYSDATE to a column that has time components, you’re losing rows. TRUNC both sides or die by off-by-one bugs.

Fourth: LISTAGG with ON OVERFLOW TRUNCATE. Reports with concatenated data? Great until a row overflows 4000 chars. Then Oracle errors. Add 'ON OVERFLOW TRUNCATE' and your report survives production data.

Fifth: REGEXP_LIKE for input validation. You catch bad data at the database level instead of hoping the frontend is honest. '^[A-Z0-9]+$' on part numbers stops injection and garbage in one regex.

SafeBuiltins.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — database tutorial

-- Bad: batch dies on bad input
-- SELECT TO_NUMBER('12A') FROM dual;  -- ORA-01722

-- Good: safe conversion with default
SELECT 
    COALESCE(
        TO_NUMBER('12A' DEFAULT 0 ON CONVERSION ERROR),
        -1
    ) AS safe_part_number
FROM dual;

-- Date range trap:
-- WHERE order_date BETWEEN SYSDATE AND SYSDATE+1  -- Wrong! includes time

-- Fix:
WHERE TRUNC(order_date) BETWEEN TRUNC(SYSDATE) AND TRUNC(SYSDATE+1);

-- Overflow-proof concatenation:
SELECT LISTAGG(description, ', ' ON OVERFLOW TRUNCATE '...') 
FROM parts_table;
Output
SAFE_PART_NUMBER
---------------
0
-- The query runs, doesn't throw, returns a usable default.
Senior Shortcut:
Use COALESCE for ANY nullable column chain. Use TO_NUMBER with DEFAULT ON CONVERSION ERROR instead of handling exceptions. Your DBA will thank you at 2 AM.
Key Takeaway
Three built-in functions — NVL/COALESCE, TRUNC, and safe TO_NUMBER — eliminate 80% of NULL and type-conversion incidents in production PL/SQL.

Terminologies in PL/SQL Subprograms — Precision Over Confusion

In production, mislabeling subprogram components causes silent logic failures. A subprogram is the generic term for any procedure or function defined in a PL/SQL block. The specification (or header) declares the name, parameters, and return type (for functions)—this is the contract. The body holds the executable statements and optional exception handlers. A parameter is a variable in the spec that receives or returns values; its mode (IN, OUT, IN OUT) determines data flow. Local variables exist only in the body’s declarative section. Formal parameters are the names in the spec; actual parameters are the values passed at call time. Mismatch between these terms in code reviews leads to hard-to-debug runtime errors. Use distinct naming conventions—prefix formal parameters with p_ and locals with l_—to enforce clarity. When debugging production incidents, reading error stacks requires knowing exactly which subprogram part failed. Get the terms right, and you read stack traces like a map, not a riddle.

SubprogramTerminology.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — database tutorial

CREATE OR REPLACE PROCEDURE validate_order(
    p_order_id IN orders.id%TYPE,      -- formal parameter
    p_status   OUT VARCHAR2             -- formal parameter with mode
) IS
    l_count NUMBER := 0;                -- local variable
BEGIN
    SELECT COUNT(*)
      INTO l_count
      FROM orders
     WHERE id = p_order_id;             -- actual parameter in use

    IF l_count = 0 THEN
        p_status := 'NOT_FOUND';
    ELSE
        p_status := 'VALID';
    END IF;
END;
Output
PL/SQL procedure successfully created.
Production Trap:
Never shadow a formal parameter name with a local variable. It silently uses the local, breaking the OUT assignment.
Key Takeaway
Formal parameters define the interface; local variables live only in the body. Never reuse names between them.

Built-in Functions in PL/SQL — Production Shortcuts That Prevent Incidents

PL/SQL built-in functions are not academic—they replace custom code that introduces bugs. Use NVL, NVL2, or COALESCE to handle nulls before they crash calculations. LEAST and GREATEST avoid multi-line IF blocks for min/max comparisons. SUBSTR, INSTR, and TRIM prevent string truncation errors in ETL pipelines. The conversion trio—TO_CHAR, TO_NUMBER, TO_DATE—must include explicit format masks to avoid locale-driven failures. For aggregates, use LNNVL to handle nulls in WHERE clauses without rewriting logic. For debugging, SQLCODE and SQLERRM in exception blocks give exact error numbers, not vague messages. DATE arithmetic is built-in: SYSDATE + 7 is valid, no function needed. The trap: assuming a built-in does what a custom function does. Always verify null behavior in your version. Write wrappers only when a built-in lacks an edge case your data demands. Otherwise, trust the built-in—it’s already tested under production load.

BuiltInFunctions.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — database tutorial

DECLARE
    l_salary   NUMBER := NULL;
    l_bonus    NUMBER;
    l_hire_dt  DATE := SYSDATE;
    l_label    VARCHAR2(20);
BEGIN
    l_bonus := COALESCE(l_salary * 0.1, 500);  -- null safe
    l_label := TO_CHAR(l_hire_dt, 'YYYY-MM-DD'); -- explicit format

    -- BAD: no format mask
    -- l_label := TO_CHAR(l_hire_dt);  -- locale dependent

    DBMS_OUTPUT.PUT_LINE('Bonus: ' || TO_CHAR(l_bonus));
EXCEPTION
    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE('Error ' || SQLCODE || ': ' || SQLERRM);
END;
Output
Bonus: 500
Production Trap:
TO_CHAR without a format mask can return different date strings across DB character sets—always specify the mask.
Key Takeaway
Built-in functions eliminate null-handling bugs and locale surprises. Use explicit format masks on all conversions.
● Production incidentPOST-MORTEMseverity: high

WHEN OTHERS THEN NULL swallows constraint violation — silent data corruption for 14 hours

Symptom
Nightly reconciliation showed $1.2M in payments recorded in the application ledger but missing from the Oracle payment table. No errors in application logs. No alerts fired. The procedure completed successfully every time.
Assumption
The team assumed a network issue between the application and database, or a message queue dropping events.
Root cause
A developer added WHEN OTHERS THEN NULL to the procedure's exception block to 'prevent crashes.' When a foreign key constraint was violated (referencing a deleted customer ID), the exception was caught and silently discarded. The COMMIT still executed, but the INSERT never happened. The application received a success response because the procedure never raised an error.
Fix
Replaced WHEN OTHERS THEN NULL with explicit exception handlers for each expected error (DUP_VAL_ON_INDEX, NO_DATA_FOUND) and a final WHEN OTHERS THEN that calls a logging procedure and re-raises with RAISE. Added a post-batch verification step that compares application-side counts with database-side counts.
Key lesson
  • WHEN OTHERS THEN NULL is never acceptable in production — it makes every error invisible
  • Always re-raise unexpected exceptions after logging: WHEN OTHERS THEN log_error(SQLERRM); RAISE;
  • Batch procedures that claim success must be verifiable — add row-count assertions after DML blocks
  • Code review must flag any exception handler that does not re-raise or explicitly handle the error
Production debug guideCommon PL/SQL procedure and function failures and how to diagnose them6 entries
Symptom · 01
Procedure compiles but status shows INVALID in USER_OBJECTS
Fix
A dependency changed. Run: SELECT name, type, referenced_name, referenced_type FROM USER_DEPENDENCIES WHERE name = 'YOUR_PROC'. Recompile with ALTER PROCEDURE name COMPILE;
Symptom · 02
ORA-14551: cannot perform a DML operation inside a query
Fix
A function called from SELECT contains INSERT/UPDATE/DELETE. Remove DML from the function or wrap it in PRAGMA AUTONOMOUS_TRANSACTION (with caution).
Symptom · 03
Procedure runs but no rows are updated — zero rows affected
Fix
Check WHERE clause parameters. Run the SELECT with the same parameters first. Common cause: parameter name shadows a column name due to missing p_ prefix convention.
Symptom · 04
Application receives ORA-06502: PL/SQL numeric or value error
Fix
A variable is too small for the data. Check VARCHAR2 length and NUMBER precision. Query USER_SOURCE to find the line, then increase the variable size.
Symptom · 05
Function returns NULL unexpectedly
Fix
Check for NULL propagation in expressions. In PL/SQL, any expression with a NULL operand returns NULL. Use NVL() or COALESCE() to provide defaults.
Symptom · 06
Slow performance — procedure takes 30+ seconds
Fix
Enable SQL tracing: ALTER SESSION SET SQL_TRACE = TRUE; Run the procedure. Analyze the trace file with TKPROF. Look for full table scans and missing indexes.
Stored Procedure vs Function in PL/SQL
FeatureStored ProcedureFunction
Return ValueDoes not return a value via header; uses OUT parameters if needed.Must return exactly one value via the RETURN clause.
Call in SQLCannot be called in a SELECT or WHERE clause.Can be called in SELECT/WHERE if it is 'pure' (no DML).
DML OperationsStandard use case; expected to change database state.Restricted; if called from SQL, it cannot perform DML.
Primary PurposeExecuting business tasks, batch processing, and DML.Computing data, transformations, and returning specific values.
Parameter ModesSupports IN, OUT, and IN OUT modes.Supports IN (standard), OUT/IN OUT (rarely used/discouraged).

Key takeaways

1
PL/SQL Stored Procedures and Functions is a core concept in PL/SQL that every Database developer should understand to build modular, secure, and fast applications.
2
Procedures are verbs (actions), and Functions are nouns (values). Choose based on whether your primary goal is to change data or calculate it.
3
Encapsulating logic in the database creates a 'Single Source of Truth' for business rules, preventing different apps from calculating the same data differently.
4
Always use explicit exception handling to ensure the integrity of your DML and to provide meaningful feedback to the middleware tier.
5
Read the official documentation
it contains edge cases like 'Result Cache' hints which can drastically speed up functions that are called frequently with the same parameters.

Common mistakes to avoid

4 patterns
×

Over-abstraction for simple SQL

Symptom
A stored procedure wraps a single SELECT statement with no procedural logic. The codebase has hundreds of thin wrappers that add no value — just indirection and maintenance burden.
Fix
Only wrap SQL in a procedure when there is added value: security masking, audit logging, input validation, or multi-step logic. A bare SELECT with a procedure name is code bloat, not modularity.
×

Dependency blindness on schema changes

Symptom
After adding a column or changing a data type, dozens of procedures and functions show INVALID status in USER_OBJECTS. Application starts throwing ORA-04063 or ORA-04068 at runtime.
Fix
Query USER_DEPENDENCIES before schema changes to identify all affected subprograms. Recompile invalid objects with: SELECT 'ALTER ' || type || ' ' || name || ' COMPILE;' FROM USER_OBJECTS WHERE status = 'INVALID';
×

Swallowing exceptions with WHEN OTHERS THEN NULL

Symptom
Procedure completes without error but data is silently missing. Application receives success response. No errors in any log. Reconciliation jobs find discrepancies days later.
Fix
Remove WHEN OTHERS THEN NULL entirely. Replace with explicit named exception handlers for expected errors, and a final WHEN OTHERS that logs (SQLERRM, context) and re-raises with RAISE.
×

Performing DML inside functions called from SQL

Symptom
ORA-14551: cannot perform a DML operation inside a query. The function works fine when called from PL/SQL but fails when used in a SELECT statement.
Fix
Remove DML from functions intended for SQL usage. If you need to log inside a function, use PRAGMA AUTONOMOUS_TRANSACTION — but understand it runs in a separate transaction and cannot see uncommitted caller data.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the fundamental difference between a Procedure and a Function? W...
Q02JUNIOR
Explain the three parameter modes: IN, OUT, and IN OUT. Which is the def...
Q03SENIOR
What is 'RAISE_APPLICATION_ERROR' and why is it better than just letting...
Q04SENIOR
How do Stored Procedures improve performance for an application communic...
Q05SENIOR
Can a PL/SQL Function be overloaded? If so, what are the requirements re...
Q06SENIOR
What is the 'PRAGMA AUTONOMOUS_TRANSACTION' and how would you use it ins...
Q01 of 06JUNIOR

What is the fundamental difference between a Procedure and a Function? When is it illegal to use a Function in a SELECT statement?

ANSWER
A Procedure performs actions and does not return a value via its header (though it can use OUT parameters). A Function must return exactly one value via the RETURN clause. A Function cannot be used in a SELECT statement if it performs DML (INSERT/UPDATE/DELETE) — Oracle enforces the WNDS (Writes No Database State) purity level for functions called from SQL. Using PRAGMA AUTONOMOUS_TRANSACTION can bypass this, but the DML runs in a separate transaction.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Can a stored procedure call a function?
02
What happens if a procedure with OUT parameters raises an exception before assigning values?
03
Can I use COMMIT inside a function?
04
What is the RESULT_CACHE hint and when should I use it?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Everything here is grounded in real deployments.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's PL/SQL. Mark it forged?

12 min read · try the examples if you haven't

Previous
PL/SQL Cursors Explained
5 / 27 · PL/SQL
Next
PL/SQL Triggers Explained