Database Management Systems

Unit 5: PL/SQL Programming

Blocks, Variables, Cursors, Stored Procedures, Functions, Triggers & Exception Handling — turning SQL into programmable enterprise logic.

🏢 Oracle PL/SQL & PostgreSQL PL/pgSQL  |  📝 15 MCQs (Bloom's)  |  🔬 5 Lab Exercises  |  💼 Interview Prep

Section 1

Why This Chapter Pays Your Salary

SQL is declarative — you tell the database what you want. PL/SQL is procedural — you tell the database how to do it. Every bank, insurance company, telecom, and government system in India runs on PL/SQL stored procedures and triggers. SBI alone has 50,000+ stored procedures managing ₹45 lakh crore in assets. If you work in enterprise IT in India, PL/SQL is not optional.

🏢 Industry Snapshot

SBI Core Banking — Every ATM withdrawal triggers a PL/SQL procedure: validate PIN, check balance, debit account, log transaction, update mini-statement — all in one atomic database call. If any step fails, the exception handler rolls back everything. Zero partial updates.

IRCTC — The Tatkal booking engine uses PL/SQL cursors to loop through waitlisted passengers when a cancellation occurs: fetch next waitlisted PNR, confirm seat, send SMS, update status — all inside a stored procedure that runs in <100ms.

LIC — Premium calculation, bonus declaration, maturity payout — all PL/SQL packages. A single pkg_policy.calculate_maturity function handles 40+ policy types with complex actuarial logic.

🇮🇳 SBI🇮🇳 IRCTC🇮🇳 LIC🇮🇳 TCS🇮🇳 InfosysOracle DBA
PL/SQL was created by Oracle Corporation in 1988. "PL" stands for Procedural Language — it extends SQL with variables, loops, conditionals, and exception handling. PostgreSQL's equivalent is PL/pgSQL (almost identical syntax). MySQL uses a different procedural syntax. 95% of Indian enterprise databases run Oracle — PL/SQL skills are career-defining.
Section 2

Learning Outcomes — Bloom's Taxonomy

Bloom's LevelOutcome Statement
L1 — RememberList the three sections of a PL/SQL block; recall cursor types; name predefined Oracle exceptions
L2 — UnderstandExplain the difference between implicit and explicit cursors; describe how exception propagation works; distinguish procedures from functions
L3 — ApplyWrite PL/SQL blocks with variables, loops, cursors, and exception handlers; create stored procedures, functions, and triggers for business logic
L4 — AnalyzeDebug PL/SQL code with cursor attribute checks; analyze trigger execution order (BEFORE/AFTER, ROW/STATEMENT); trace exception propagation through nested blocks
L5 — EvaluateEvaluate when to use procedures vs functions vs triggers; justify cursor FOR loop over explicit OPEN/FETCH/CLOSE; assess trigger design for audit systems
L6 — CreateDesign a complete PL/SQL package for a banking system with procedures, functions, cursors, exceptions, and triggers working together
Section 3

Concept Explanations

3.1 PL/SQL Block Structure

📌 The PL/SQL Block — Your First Program

📌 WHAT IT IS

A PL/SQL block is the fundamental unit of PL/SQL code. Every PL/SQL program — anonymous block, procedure, function, trigger — follows the same three-section structure:

PL/SQL — Block Structure
-- ═══════════════════════════════════════
-- Anonymous Block: Hello World
-- ═══════════════════════════════════════

DECLARE                          -- (Optional) Variable declarations
    v_student_name  VARCHAR2(50);
    v_total_marks   NUMBER(5,2);
    v_pass          BOOLEAN := FALSE;
BEGIN                             -- (Required) Executable statements
    v_student_name := 'Rahul Sharma';
    v_total_marks  := 87.5;

    IF v_total_marks >= 40 THEN
        v_pass := TRUE;
        DBMS_OUTPUT.PUT_LINE(v_student_name || ' PASSED with ' || v_total_marks || '%');
    ELSE
        DBMS_OUTPUT.PUT_LINE(v_student_name || ' FAILED');
    END IF;
EXCEPTION                        -- (Optional) Error handling
    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE('Error: ' || SQLERRM);
END;
/

-- Output: Rahul Sharma PASSED with 87.5%
SectionKeywordRequired?Purpose
DeclarationDECLAREOptionalDefine variables, constants, cursors, user-defined exceptions
ExecutableBEGIN...ENDRequiredSQL statements, PL/SQL logic, procedure calls
ExceptionEXCEPTIONOptionalHandle runtime errors gracefully
Enable output in SQL*Plus / SQL Developer: Run SET SERVEROUTPUT ON before executing PL/SQL blocks, otherwise DBMS_OUTPUT.PUT_LINE produces no visible output. In PostgreSQL, use RAISE NOTICE 'message' instead.

3.2 Variables, Constants & Data Types

PL/SQL — Variables & Types
DECLARE
    -- Scalar variables
    v_emp_id        NUMBER(10)       := 1001;
    v_emp_name      VARCHAR2(100)   := 'Priya Patel';
    v_salary        NUMBER(10,2)    := 75000.00;
    v_join_date     DATE            := SYSDATE;
    v_is_active     BOOLEAN         := TRUE;

    -- Constants (cannot be changed after declaration)
    c_tax_rate      CONSTANT NUMBER := 0.30;
    c_company       CONSTANT VARCHAR2(20) := 'TCS';

    -- %TYPE: anchored to a table column's data type (RECOMMENDED)
    v_cust_name     customers.customer_name%TYPE;    -- Same type as the column
    v_price         products.price%TYPE;

    -- %ROWTYPE: entire row structure of a table
    v_emp_record    employees%ROWTYPE;    -- Has .emp_id, .name, .salary, etc.

    -- User-defined record type
    TYPE t_address IS RECORD (
        street   VARCHAR2(100),
        city     VARCHAR2(50),
        pincode  VARCHAR2(6)
    );
    v_addr  t_address;
BEGIN
    -- Using %TYPE: if column type changes, your code adapts automatically
    SELECT customer_name INTO v_cust_name FROM customers WHERE customer_id = 1;

    -- Using %ROWTYPE: fetch entire row
    SELECT * INTO v_emp_record FROM employees WHERE emp_id = 1001;
    DBMS_OUTPUT.PUT_LINE(v_emp_record.emp_name || ' earns ' || v_emp_record.salary);

    -- Record type usage
    v_addr.street  := 'MG Road';
    v_addr.city    := 'Bangalore';
    v_addr.pincode := '560001';
END;
/
%TYPE and %ROWTYPE are industry best practices. If a DBA changes customers.customer_name from VARCHAR2(50) to VARCHAR2(100), all PL/SQL code using customers.customer_name%TYPE automatically adapts without modification. Hard-coding types like VARCHAR2(50) breaks when column definitions change. Always use %TYPE in production code.

3.3 Control Structures — IF, CASE, Loops

PL/SQL — Control Structures
DECLARE
    v_marks   NUMBER := 72;
    v_grade   VARCHAR2(2);
    v_counter NUMBER;
BEGIN
    -- ═══ IF-ELSIF-ELSE ═══
    IF v_marks >= 90 THEN
        v_grade := 'A+';
    ELSIF v_marks >= 80 THEN     -- Note: ELSIF not ELSEIF
        v_grade := 'A';
    ELSIF v_marks >= 70 THEN
        v_grade := 'B+';
    ELSIF v_marks >= 60 THEN
        v_grade := 'B';
    ELSE
        v_grade := 'F';
    END IF;

    -- ═══ CASE Expression ═══
    v_grade := CASE
        WHEN v_marks >= 90 THEN 'A+'
        WHEN v_marks >= 80 THEN 'A'
        WHEN v_marks >= 70 THEN 'B+'
        ELSE 'F'
    END;

    -- ═══ BASIC LOOP (loop...exit when) ═══
    v_counter := 1;
    LOOP
        DBMS_OUTPUT.PUT_LINE('Iteration: ' || v_counter);
        v_counter := v_counter + 1;
        EXIT WHEN v_counter > 5;   -- Exit condition (mandatory!)
    END LOOP;

    -- ═══ WHILE LOOP ═══
    v_counter := 1;
    WHILE v_counter <= 5 LOOP
        DBMS_OUTPUT.PUT_LINE('While: ' || v_counter);
        v_counter := v_counter + 1;
    END LOOP;

    -- ═══ FOR LOOP (most common) ═══
    FOR i IN 1..5 LOOP            -- i is auto-declared, 1 to 5 inclusive
        DBMS_OUTPUT.PUT_LINE('For: ' || i);
    END LOOP;

    -- ═══ FOR LOOP (reverse) ═══
    FOR i IN REVERSE 1..5 LOOP    -- 5 down to 1
        DBMS_OUTPUT.PUT_LINE('Reverse: ' || i);
    END LOOP;
END;
/
ELSIF not ELSEIF! Oracle PL/SQL uses ELSIF (no second E). Writing ELSEIF causes a compilation error. PostgreSQL PL/pgSQL also uses ELSIF. This trips up students who know Python/Java where it's elif or else if.

3.4 Cursors — Row-by-Row Processing

📌 Cursors — Processing Query Results One Row at a Time

📌 WHAT IT IS

A cursor is a pointer to a private SQL area (context area) that holds the result set of a SELECT query. It allows you to process rows one at a time — essential when you need to perform complex logic on each row that SQL alone can't express.

🌍 REAL-WORLD ANALOGY

A cursor is like a bookmark in a book. The book (result set) has many pages (rows). The bookmark (cursor) points to the current page. You read one page, move the bookmark forward, read the next — until you reach the end.

Implicit Cursor

PL/SQL — Implicit Cursor
-- Oracle automatically creates an implicit cursor for every DML/SELECT INTO
DECLARE
    v_name  customers.customer_name%TYPE;
    v_rows  NUMBER;
BEGIN
    -- SELECT INTO creates an implicit cursor
    SELECT customer_name INTO v_name
    FROM customers WHERE customer_id = 1;
    DBMS_OUTPUT.PUT_LINE('Customer: ' || v_name);

    -- UPDATE creates an implicit cursor too
    UPDATE products SET price = price * 1.10 WHERE category_id = 1;

    -- Implicit cursor attributes (use SQL% prefix)
    IF SQL%FOUND THEN
        DBMS_OUTPUT.PUT_LINE(SQL%ROWCOUNT || ' products updated');
    ELSE
        DBMS_OUTPUT.PUT_LINE('No products found in category 1');
    END IF;
END;
/

Explicit Cursor

PL/SQL — Explicit Cursor (Full Lifecycle)
-- ═══════════════════════════════════════
-- Business Context: Generate invoice for all delivered orders
-- ═══════════════════════════════════════
DECLARE
    -- Step 1: DECLARE the cursor
    CURSOR cur_delivered_orders IS
        SELECT o.order_id, c.customer_name,
               SUM(oi.quantity * oi.unit_price) AS total
        FROM orders o
        JOIN customers c ON o.customer_id = c.customer_id
        JOIN order_items oi ON o.order_id = oi.order_id
        WHERE o.status = 'DELIVERED'
        GROUP BY o.order_id, c.customer_name;

    v_order_id      orders.order_id%TYPE;
    v_cust_name     customers.customer_name%TYPE;
    v_total         NUMBER(12,2);
    v_invoice_count NUMBER := 0;
BEGIN
    -- Step 2: OPEN the cursor (executes the query)
    OPEN cur_delivered_orders;

    -- Step 3: FETCH rows one by one
    LOOP
        FETCH cur_delivered_orders INTO v_order_id, v_cust_name, v_total;
        EXIT WHEN cur_delivered_orders%NOTFOUND;   -- Exit when no more rows

        v_invoice_count := v_invoice_count + 1;
        DBMS_OUTPUT.PUT_LINE(
            'Invoice #' || v_invoice_count ||
            ' | Order: ' || v_order_id ||
            ' | Customer: ' || v_cust_name ||
            ' | Total: ₹' || TO_CHAR(v_total, '99,99,999.00')
        );
    END LOOP;

    -- Step 4: CLOSE the cursor (release memory)
    CLOSE cur_delivered_orders;

    DBMS_OUTPUT.PUT_LINE('Total invoices generated: ' || v_invoice_count);
END;
/

Cursor FOR Loop (RECOMMENDED)

PL/SQL — Cursor FOR Loop
-- The cursor FOR loop auto-handles OPEN, FETCH, CLOSE, and %NOTFOUND
-- MUCH cleaner than explicit OPEN/FETCH/CLOSE

BEGIN
    FOR rec IN (
        SELECT o.order_id, c.customer_name,
               SUM(oi.quantity * oi.unit_price) AS total
        FROM orders o
        JOIN customers c ON o.customer_id = c.customer_id
        JOIN order_items oi ON o.order_id = oi.order_id
        WHERE o.status = 'DELIVERED'
        GROUP BY o.order_id, c.customer_name
    ) LOOP
        DBMS_OUTPUT.PUT_LINE(
            'Order ' || rec.order_id || ' — ' || rec.customer_name ||
            ' — ₹' || rec.total
        );
    END LOOP;  -- Auto-closes cursor, handles empty result set gracefully
END;
/

Cursor Attributes

AttributeExplicit CursorImplicit Cursor (SQL%)
%FOUNDTRUE if last FETCH returned a rowTRUE if last DML affected ≥1 row
%NOTFOUNDTRUE if last FETCH returned no rowTRUE if last DML affected 0 rows
%ROWCOUNTNumber of rows fetched so farNumber of rows affected by last DML
%ISOPENTRUE if cursor is currently openAlways FALSE (Oracle auto-closes)

3.5 Exception Handling

PL/SQL — Exception Handling
-- ═══════════════════════════════════════
-- Business Context: Safe customer lookup with error handling
-- ═══════════════════════════════════════
DECLARE
    v_name  customers.customer_name%TYPE;
    v_bal   NUMBER;
BEGIN
    SELECT customer_name INTO v_name
    FROM customers WHERE customer_id = 999;   -- Doesn't exist!

    DBMS_OUTPUT.PUT_LINE('Found: ' || v_name);

EXCEPTION
    WHEN NO_DATA_FOUND THEN
        DBMS_OUTPUT.PUT_LINE('Customer 999 not found!');

    WHEN TOO_MANY_ROWS THEN
        DBMS_OUTPUT.PUT_LINE('Query returned multiple rows — use a cursor!');

    WHEN ZERO_DIVIDE THEN
        DBMS_OUTPUT.PUT_LINE('Division by zero error');

    WHEN OTHERS THEN
        DBMS_OUTPUT.PUT_LINE('Unexpected error: ' || SQLCODE || ' — ' || SQLERRM);
        RAISE;  -- Re-raise the exception to the calling environment
END;
/

Predefined Oracle Exceptions

Exception NameORA CodeWhen It Fires
NO_DATA_FOUNDORA-01403SELECT INTO returns zero rows
TOO_MANY_ROWSORA-01422SELECT INTO returns >1 row
ZERO_DIVIDEORA-01476Division by zero
VALUE_ERRORORA-06502Arithmetic/conversion/truncation error
INVALID_CURSORORA-01001Cursor operation on unopened cursor
DUP_VAL_ON_INDEXORA-00001Unique constraint violation on INSERT
CURSOR_ALREADY_OPENORA-06511Opening an already-open cursor

User-Defined Exceptions

PL/SQL — Custom Exceptions
DECLARE
    e_insufficient_balance  EXCEPTION;             -- Declare custom exception
    e_daily_limit_exceeded  EXCEPTION;
    PRAGMA EXCEPTION_INIT(e_daily_limit_exceeded, -20001);  -- Map to error code

    v_balance  NUMBER := 5000;
    v_withdraw NUMBER := 8000;
BEGIN
    IF v_withdraw > v_balance THEN
        RAISE e_insufficient_balance;              -- Raise custom exception
    END IF;

    -- Alternative: RAISE_APPLICATION_ERROR (sends error to client)
    -- RAISE_APPLICATION_ERROR(-20001, 'Insufficient balance: ₹' || v_balance);

EXCEPTION
    WHEN e_insufficient_balance THEN
        DBMS_OUTPUT.PUT_LINE('ERROR: Balance ₹' || v_balance ||
            ' insufficient for withdrawal ₹' || v_withdraw);
END;
/

3.6 Stored Procedures & Functions

PL/SQL — Stored Procedure
-- ═══════════════════════════════════════
-- Business Context: Bank fund transfer procedure
-- Used by: SBI ATM network, mobile banking apps
-- ═══════════════════════════════════════
CREATE OR REPLACE PROCEDURE sp_transfer_funds (
    p_from_acc   IN  NUMBER,             -- IN: input parameter
    p_to_acc     IN  NUMBER,
    p_amount     IN  NUMBER,
    p_status     OUT VARCHAR2,           -- OUT: output parameter
    p_new_bal    OUT NUMBER
) AS
    v_from_balance  NUMBER;
    e_insufficient  EXCEPTION;
BEGIN
    -- Check source balance
    SELECT balance INTO v_from_balance
    FROM accounts WHERE account_id = p_from_acc
    FOR UPDATE;  -- Lock the row to prevent concurrent modification

    IF v_from_balance < p_amount THEN
        RAISE e_insufficient;
    END IF;

    -- Debit source
    UPDATE accounts SET balance = balance - p_amount
    WHERE account_id = p_from_acc;

    -- Credit destination
    UPDATE accounts SET balance = balance + p_amount
    WHERE account_id = p_to_acc;

    -- Log transaction
    INSERT INTO transactions (from_acc, to_acc, amount, txn_date, txn_type)
    VALUES (p_from_acc, p_to_acc, p_amount, SYSDATE, 'TRANSFER');

    COMMIT;
    p_status  := 'SUCCESS';
    p_new_bal := v_from_balance - p_amount;

EXCEPTION
    WHEN e_insufficient THEN
        ROLLBACK;
        p_status := 'FAILED: Insufficient balance';
    WHEN NO_DATA_FOUND THEN
        ROLLBACK;
        p_status := 'FAILED: Account not found';
    WHEN OTHERS THEN
        ROLLBACK;
        p_status := 'FAILED: ' || SQLERRM;
END sp_transfer_funds;
/

-- Execute the procedure:
DECLARE
    v_status  VARCHAR2(200);
    v_bal     NUMBER;
BEGIN
    sp_transfer_funds(1001, 1002, 5000, v_status, v_bal);
    DBMS_OUTPUT.PUT_LINE(v_status || ' | New Balance: ₹' || v_bal);
END;
/

Functions (Return a Single Value)

PL/SQL — Function
-- ═══════════════════════════════════════
-- Business Context: Calculate GST for an invoice
-- ═══════════════════════════════════════
CREATE OR REPLACE FUNCTION fn_calc_gst (
    p_amount     IN NUMBER,
    p_gst_slab   IN VARCHAR2 DEFAULT '18'  -- Default 18%
) RETURN NUMBER
AS
    v_rate  NUMBER;
    v_gst   NUMBER;
BEGIN
    v_rate := CASE p_gst_slab
        WHEN '0'  THEN 0      -- Essential goods
        WHEN '5'  THEN 0.05   -- Food, transport
        WHEN '12' THEN 0.12   -- Business class
        WHEN '18' THEN 0.18   -- Standard (most services)
        WHEN '28' THEN 0.28   -- Luxury (cars, tobacco)
        ELSE 0.18
    END;

    v_gst := ROUND(p_amount * v_rate, 2);
    RETURN v_gst;
END fn_calc_gst;
/

-- Use function in SQL (unlike procedures):
SELECT product_name, price,
       fn_calc_gst(price, '18') AS gst_amount,
       price + fn_calc_gst(price, '18') AS total_with_gst
FROM products;

Procedure vs Function

FeatureProcedureFunction
RETURN valueNo (uses OUT parameters)Yes — exactly ONE return value
Use in SQL❌ Cannot call in SELECT✅ Can call in SELECT, WHERE, etc.
PurposePerform an action (INSERT, UPDATE, transfers)Compute and return a value (GST, age, discount)
DML inside✅ Allowed⚠️ Allowed but breaks SQL usage if function has side effects
ParametersIN, OUT, IN OUTMostly IN only
COMMIT/ROLLBACK✅ Allowed⚠️ Avoid in functions called from SQL

3.7 Triggers — Automatic Actions on Data Changes

PL/SQL — Trigger Types
-- ═══════════════════════════════════════
-- BEFORE ROW trigger: Audit trail for salary changes
-- Business Context: HR audit — log every salary modification
-- ═══════════════════════════════════════
CREATE OR REPLACE TRIGGER trg_salary_audit
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
WHEN (OLD.salary != NEW.salary)   -- Only when salary actually changes
BEGIN
    INSERT INTO salary_audit_log (
        emp_id, old_salary, new_salary,
        changed_by, changed_at, change_pct
    ) VALUES (
        :OLD.emp_id,
        :OLD.salary,
        :NEW.salary,
        USER,
        SYSDATE,
        ROUND((:NEW.salary - :OLD.salary) * 100 / :OLD.salary, 2)
    );

    -- Business rule: prevent salary decrease > 20%
    IF :NEW.salary < :OLD.salary * 0.80 THEN
        RAISE_APPLICATION_ERROR(-20002,
            'Salary decrease cannot exceed 20%. Old: ₹' || :OLD.salary ||
            ', New: ₹' || :NEW.salary);
    END IF;
END;
/

-- ═══════════════════════════════════════
-- AFTER INSERT trigger: Auto-generate order confirmation
-- ═══════════════════════════════════════
CREATE OR REPLACE TRIGGER trg_order_confirm
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
    INSERT INTO notifications (customer_id, message, created_at)
    VALUES (
        :NEW.customer_id,
        'Order #' || :NEW.order_id || ' placed successfully!',
        SYSDATE
    );
END;
/

-- ═══════════════════════════════════════
-- INSTEAD OF trigger: Enable INSERT on a complex view
-- ═══════════════════════════════════════
CREATE OR REPLACE TRIGGER trg_order_view_insert
INSTEAD OF INSERT ON v_order_summary
FOR EACH ROW
BEGIN
    INSERT INTO orders (order_id, customer_id, order_date, status)
    VALUES (:NEW.order_id, :NEW.customer_id, SYSDATE, 'PENDING');
END;
/

Trigger Timing & Types

TimingTypeFiresUse Case
BEFOREROWBefore each row is modifiedValidation, auto-populate fields, prevent invalid data
BEFORESTATEMENTOnce before the DML beginsCheck permissions, set session variables
AFTERROWAfter each row is modifiedAudit logging, cascading updates, notifications
AFTERSTATEMENTOnce after the DML completesSummary recalculation, batch logging
INSTEAD OFROW (views only)Replaces the DML on a viewEnable DML on complex views with JOINs
Mutating table error (ORA-04091): A row-level trigger CANNOT query or modify the same table that fired it. Example: AFTER UPDATE ON employees FOR EACH ROW cannot SELECT FROM employees inside the trigger body. Fix: use a compound trigger or a statement-level trigger + package variable to collect row data first, then process in AFTER STATEMENT.

3.8 Packages — Organizing PL/SQL Code

PL/SQL — Package Specification
-- ═══════════════════════════════════════
-- Package: Banking operations module
-- Specification = public interface (what others can see)
-- ═══════════════════════════════════════
CREATE OR REPLACE PACKAGE pkg_banking AS
    -- Public constants
    c_min_balance  CONSTANT NUMBER := 1000;
    c_max_withdraw CONSTANT NUMBER := 200000;

    -- Public procedures
    PROCEDURE deposit    (p_acc_id IN NUMBER, p_amount IN NUMBER);
    PROCEDURE withdraw   (p_acc_id IN NUMBER, p_amount IN NUMBER);
    PROCEDURE transfer   (p_from IN NUMBER, p_to IN NUMBER, p_amt IN NUMBER);

    -- Public function
    FUNCTION  get_balance (p_acc_id IN NUMBER) RETURN NUMBER;
END pkg_banking;
/
PL/SQL — Package Body
CREATE OR REPLACE PACKAGE BODY pkg_banking AS

    -- Private helper (not in specification — hidden from callers)
    PROCEDURE log_transaction(p_acc NUMBER, p_type VARCHAR2, p_amt NUMBER) IS
    BEGIN
        INSERT INTO txn_log (account_id, txn_type, amount, txn_date)
        VALUES (p_acc, p_type, p_amt, SYSDATE);
    END;

    PROCEDURE deposit(p_acc_id IN NUMBER, p_amount IN NUMBER) IS
    BEGIN
        UPDATE accounts SET balance = balance + p_amount
        WHERE account_id = p_acc_id;
        log_transaction(p_acc_id, 'DEPOSIT', p_amount);
        COMMIT;
    END;

    PROCEDURE withdraw(p_acc_id IN NUMBER, p_amount IN NUMBER) IS
        v_bal NUMBER;
    BEGIN
        SELECT balance INTO v_bal FROM accounts
        WHERE account_id = p_acc_id FOR UPDATE;

        IF p_amount > c_max_withdraw THEN
            RAISE_APPLICATION_ERROR(-20003, 'Exceeds daily withdrawal limit');
        ELSIF (v_bal - p_amount) < c_min_balance THEN
            RAISE_APPLICATION_ERROR(-20004, 'Minimum balance ₹' || c_min_balance || ' required');
        END IF;

        UPDATE accounts SET balance = balance - p_amount
        WHERE account_id = p_acc_id;
        log_transaction(p_acc_id, 'WITHDRAW', p_amount);
        COMMIT;
    END;

    PROCEDURE transfer(p_from IN NUMBER, p_to IN NUMBER, p_amt IN NUMBER) IS
    BEGIN
        withdraw(p_from, p_amt);
        deposit(p_to, p_amt);
    END;

    FUNCTION get_balance(p_acc_id IN NUMBER) RETURN NUMBER IS
        v_bal NUMBER;
    BEGIN
        SELECT balance INTO v_bal FROM accounts WHERE account_id = p_acc_id;
        RETURN v_bal;
    EXCEPTION
        WHEN NO_DATA_FOUND THEN RETURN -1;
    END;

END pkg_banking;
/

-- Usage:
BEGIN
    pkg_banking.deposit(1001, 25000);
    pkg_banking.withdraw(1001, 10000);
    DBMS_OUTPUT.PUT_LINE('Balance: ₹' || pkg_banking.get_balance(1001));
END;
/
PostgreSQL equivalent (PL/pgSQL): PostgreSQL uses CREATE FUNCTION for both procedures and functions (though CREATE PROCEDURE was added in PG 11). Key syntax differences: $$ dollar-quoting instead of /, RAISE NOTICE instead of DBMS_OUTPUT, RETURNS void for procedures. PostgreSQL doesn't have packages — use schemas and separate functions to organize code. Example: CREATE FUNCTION banking.deposit(p_acc INT, p_amount NUMERIC) RETURNS void AS $$ BEGIN ... END; $$ LANGUAGE plpgsql;
Section 4

Industry Problems

🏢 Industry Problem #1 — SBI ATM Withdrawal System

Scenario: Design a stored procedure for ATM cash withdrawal. Must: validate PIN, check balance, enforce daily limit (₹2,00,000), debit account, log transaction, dispense cash notification — all atomically.

💡 Complete Solution
PL/SQL
CREATE OR REPLACE PROCEDURE sp_atm_withdraw (
    p_card_no    IN  VARCHAR2,
    p_pin        IN  VARCHAR2,
    p_amount     IN  NUMBER,
    p_status     OUT VARCHAR2,
    p_balance    OUT NUMBER
) AS
    v_acc_id          NUMBER;
    v_stored_pin      VARCHAR2(64);
    v_balance         NUMBER;
    v_today_withdrawn NUMBER;
    c_daily_limit     CONSTANT NUMBER := 200000;
    c_min_balance     CONSTANT NUMBER := 1000;
BEGIN
    -- Step 1: Validate card and PIN
    SELECT account_id, pin_hash INTO v_acc_id, v_stored_pin
    FROM atm_cards WHERE card_number = p_card_no AND is_active = 'Y';

    IF v_stored_pin != DBMS_CRYPTO.HASH(UTL_RAW.CAST_TO_RAW(p_pin), 2) THEN
        p_status := 'FAILED: Invalid PIN';
        RETURN;
    END IF;

    -- Step 2: Check balance
    SELECT balance INTO v_balance FROM accounts
    WHERE account_id = v_acc_id FOR UPDATE;

    IF (v_balance - p_amount) < c_min_balance THEN
        p_status := 'FAILED: Insufficient balance';
        RETURN;
    END IF;

    -- Step 3: Check daily limit
    SELECT NVL(SUM(amount), 0) INTO v_today_withdrawn
    FROM transactions
    WHERE account_id = v_acc_id
      AND txn_type = 'ATM_WITHDRAW'
      AND TRUNC(txn_date) = TRUNC(SYSDATE);

    IF (v_today_withdrawn + p_amount) > c_daily_limit THEN
        p_status := 'FAILED: Daily limit ₹' || c_daily_limit || ' exceeded';
        RETURN;
    END IF;

    -- Step 4: Debit and log
    UPDATE accounts SET balance = balance - p_amount WHERE account_id = v_acc_id;

    INSERT INTO transactions (account_id, txn_type, amount, txn_date, channel)
    VALUES (v_acc_id, 'ATM_WITHDRAW', p_amount, SYSDATE, 'ATM');

    COMMIT;
    p_status  := 'SUCCESS';
    p_balance := v_balance - p_amount;

EXCEPTION
    WHEN NO_DATA_FOUND THEN
        p_status := 'FAILED: Invalid card';
    WHEN OTHERS THEN
        ROLLBACK;
        p_status := 'FAILED: System error — ' || SQLERRM;
END;
/

🏢 Industry Problem #2 — IRCTC Waitlist Processing

Scenario: When a passenger cancels a confirmed ticket, the system must automatically confirm the first waitlisted passenger for that train/date/class. Use a cursor to loop through waitlisted PNRs in order.

💡 Solution (Cursor + Procedure)
PL/SQL
CREATE OR REPLACE PROCEDURE sp_process_waitlist (
    p_train_no    IN VARCHAR2,
    p_journey_dt  IN DATE,
    p_class       IN VARCHAR2,
    p_seats_freed IN NUMBER
) AS
    CURSOR cur_waitlist IS
        SELECT pnr, passenger_name, phone
        FROM bookings
        WHERE train_no = p_train_no
          AND journey_date = p_journey_dt
          AND class = p_class
          AND status = 'WL'
        ORDER BY wl_number ASC   -- First waitlisted first
        FOR UPDATE OF status;    -- Lock rows for update
    v_confirmed NUMBER := 0;
BEGIN
    FOR rec IN cur_waitlist LOOP
        EXIT WHEN v_confirmed >= p_seats_freed;

        UPDATE bookings SET status = 'CNF'
        WHERE CURRENT OF cur_waitlist;

        -- Queue SMS notification
        INSERT INTO sms_queue (phone, message, status)
        VALUES (rec.phone,
            'PNR ' || rec.pnr || ' confirmed! Train ' || p_train_no,
            'PENDING');

        v_confirmed := v_confirmed + 1;
    END LOOP;

    COMMIT;
    DBMS_OUTPUT.PUT_LINE(v_confirmed || ' passengers confirmed from waitlist');
END;
/

🏢 Industry Problem #3 — GST Invoice Generator with Trigger

Scenario: Create a trigger that automatically calculates GST (CGST + SGST or IGST) when an invoice item is inserted, and a function that generates invoice number in format INV/2024-25/00001.

💡 Solution
PL/SQL
-- Function: Generate financial year invoice number
CREATE OR REPLACE FUNCTION fn_next_invoice_no RETURN VARCHAR2 AS
    v_fy     VARCHAR2(7);
    v_seq    NUMBER;
BEGIN
    -- Determine financial year (April-March in India)
    IF EXTRACT(MONTH FROM SYSDATE) >= 4 THEN
        v_fy := TO_CHAR(SYSDATE,'YYYY') || '-' || SUBSTR(TO_CHAR(SYSDATE,'YYYY')+1,3);
    ELSE
        v_fy := TO_CHAR(EXTRACT(YEAR FROM SYSDATE)-1) || '-' || TO_CHAR(SYSDATE,'YY');
    END IF;

    SELECT NVL(MAX(seq_no),0) + 1 INTO v_seq
    FROM invoices WHERE fy = v_fy;

    RETURN 'INV/' || v_fy || '/' || LPAD(v_seq, 5, '0');
END;
/

-- Trigger: Auto-calculate GST on invoice item insert
CREATE OR REPLACE TRIGGER trg_calc_gst
BEFORE INSERT ON invoice_items
FOR EACH ROW
BEGIN
    :NEW.taxable_amount := :NEW.quantity * :NEW.unit_price;
    :NEW.gst_amount     := ROUND(:NEW.taxable_amount * :NEW.gst_rate / 100, 2);
    :NEW.cgst           := ROUND(:NEW.gst_amount / 2, 2);  -- Intra-state
    :NEW.sgst           := :NEW.cgst;
    :NEW.total_amount   := :NEW.taxable_amount + :NEW.gst_amount;
END;
/
Section 5

Lab Exercises

Exercise 1: PL/SQL Blocks & Control Structures

⏱ 30 minutes🟢 Beginner

Tasks:

  1. Write a block that takes a student's marks and prints grade (A+/A/B+/B/C/F) using IF-ELSIF
  2. Rewrite using CASE expression
  3. Print multiplication table (1-10) of a given number using FOR loop
  4. Find factorial of a number using WHILE loop
  5. Use a nested block: outer block declares name, inner block declares marks, compute grade in inner, print in outer

Exercise 2: Cursors — Explicit & FOR Loop

⏱ 45 minutes🟡 Intermediate

Tasks:

  1. Explicit cursor: Loop through all products, print those with price > ₹5000
  2. Cursor FOR loop: Generate an order summary report (customer, product, quantity, total)
  3. Parameterized cursor: Accept category_id as parameter, display products in that category
  4. Cursor with FOR UPDATE: Give 10% salary raise to all employees in a department, print old → new salary
  5. Print cursor attributes (%ROWCOUNT, %FOUND) at each iteration

Exercise 3: Stored Procedures & Functions

⏱ 50 minutes🟡 Intermediate

Tasks:

  1. Create procedure sp_add_customer with IN parameters (name, city, phone) and OUT parameter (new customer_id)
  2. Create function fn_get_order_total that accepts order_id and returns the sum of (quantity × unit_price)
  3. Create function fn_age_in_years that accepts a DATE and returns age in years — use in SELECT
  4. Create procedure sp_update_stock with IN OUT parameter: pass current stock, deduct quantity, return new stock
  5. Handle NO_DATA_FOUND, TOO_MANY_ROWS, and a custom exception in one procedure

Exercise 4: Triggers — Audit & Business Rules

⏱ 45 minutes🟡 Intermediate

Tasks:

  1. BEFORE INSERT trigger: Auto-set created_at and created_by on orders table
  2. AFTER UPDATE trigger: Log salary changes to salary_audit table with old/new values
  3. BEFORE DELETE trigger: Prevent deletion of orders with status 'DELIVERED' (raise error)
  4. BEFORE INSERT trigger: Ensure product price is always > 0 (modify :NEW.price if negative)
  5. Create the audit log table and verify all triggers work with test DML statements

Exercise 5: Complete Banking Package

⏱ 60 minutes🔴 Advanced

Tasks: Create a complete pkg_banking package:

  1. Specification: declare deposit, withdraw, transfer procedures; get_balance, get_mini_statement functions
  2. Body: implement all procedures with proper exception handling and transaction control
  3. get_mini_statement: use a cursor to return last 5 transactions
  4. Add a trigger on accounts table: log every balance change to audit table
  5. Test: deposit ₹50,000, withdraw ₹20,000, transfer ₹10,000 to another account, print mini-statement
Section 6

MCQ Assessment Bank — 15 Questions

Hover to reveal answer and explanation.

Q1

Which section of a PL/SQL block is mandatory?

  1. DECLARE
  2. BEGIN...END
  3. EXCEPTION
  4. All three are mandatory
B. Only BEGIN...END is mandatory. DECLARE is optional (if you don't need variables). EXCEPTION is optional (if you don't handle errors). The simplest valid PL/SQL block: BEGIN NULL; END;.
🏢 Basic structure question — expected in every PL/SQL interview.
L1 — RememberBlock Structure
Q2

What does %TYPE do in a PL/SQL variable declaration?

  1. Creates a new data type
  2. Anchors the variable's data type to a specific table column — if the column type changes, the variable adapts automatically
  3. Converts data types at runtime
  4. Defines a constant
B. v_name customers.customer_name%TYPE makes v_name the same type as the customer_name column. If a DBA changes the column from VARCHAR2(50) to VARCHAR2(100), all PL/SQL code using %TYPE automatically adapts. This is an Oracle best practice — hard-coding types causes maintenance nightmares.
🏢 Always use %TYPE in production. Interviewers check if you know this.
L1 — RememberVariables
Q3

Which predefined exception is raised when SELECT INTO returns zero rows?

  1. TOO_MANY_ROWS
  2. NO_DATA_FOUND
  3. VALUE_ERROR
  4. INVALID_CURSOR
B. NO_DATA_FOUND (ORA-01403). SELECT INTO must return exactly ONE row. Zero rows → NO_DATA_FOUND. More than one row → TOO_MANY_ROWS (ORA-01422). Both must be handled in production code. VALUE_ERROR is for type conversion failures.
🏢 NO_DATA_FOUND is the #1 most commonly encountered PL/SQL exception in production.
L1 — RememberExceptions
Q4

What is the difference between an implicit cursor and an explicit cursor?

  1. They are identical
  2. An implicit cursor is automatically created by Oracle for every DML/SELECT INTO. An explicit cursor is declared, opened, fetched, and closed by the programmer — used for multi-row query processing
  3. Implicit cursors are faster
  4. Explicit cursors can only be used in functions
B. Implicit: Oracle creates it behind the scenes for INSERT, UPDATE, DELETE, SELECT INTO. Access via SQL%FOUND, SQL%ROWCOUNT. Explicit: you DECLARE it, OPEN it, FETCH rows in a loop, CLOSE it. Used when you need to process multiple rows one by one. Cursor FOR loops simplify explicit cursors by auto-handling OPEN/FETCH/CLOSE.
🏢 Cursor lifecycle (DECLARE → OPEN → FETCH → CLOSE) is a fundamental PL/SQL concept.
L2 — UnderstandCursors
Q5

What is the difference between a stored procedure and a function?

  1. They are identical
  2. A function MUST return exactly one value using RETURN and can be called in SQL (SELECT, WHERE). A procedure performs actions, uses OUT parameters for output, and CANNOT be called in SQL statements
  3. Procedures are faster
  4. Functions cannot have parameters
B. Key difference: function has RETURN clause and returns a value — can be used in SELECT: SELECT fn_calc_gst(price) FROM products. Procedure uses OUT parameters — called from PL/SQL blocks: sp_transfer(1001, 1002, 5000, v_status). Procedures are for actions (DML), functions are for computations (calculations).
🏢 This is asked in every Oracle/PL/SQL interview. Know the 5 key differences.
L2 — UnderstandProc vs Func
Q6

What does :OLD and :NEW refer to in a trigger?

  1. Old and new tables
  2. :OLD refers to the row values BEFORE the DML operation; :NEW refers to the values AFTER. In INSERT triggers, only :NEW exists. In DELETE triggers, only :OLD exists. In UPDATE triggers, both exist
  3. They are variable prefixes
  4. They refer to old and new databases
B. :OLD and :NEW are pseudo-records available in row-level triggers. INSERT: :OLD is NULL (no previous row), :NEW has the new values. DELETE: :OLD has the existing values, :NEW is NULL. UPDATE: :OLD has before-update values, :NEW has after-update values. You can modify :NEW in BEFORE triggers (e.g., :NEW.updated_at := SYSDATE).
🏢 Understanding :OLD/:NEW is essential for audit triggers — used in every enterprise application.
L2 — UnderstandTriggers
Q7

Write a PL/SQL function that accepts an employee_id and returns annual salary (monthly_salary × 12).

  1. CREATE PROCEDURE fn_annual(p_id NUMBER) AS BEGIN SELECT salary*12 FROM emp WHERE emp_id=p_id; END;
  2. CREATE OR REPLACE FUNCTION fn_annual_salary(p_emp_id IN NUMBER) RETURN NUMBER AS v_sal NUMBER; BEGIN SELECT salary*12 INTO v_sal FROM employees WHERE emp_id=p_emp_id; RETURN v_sal; EXCEPTION WHEN NO_DATA_FOUND THEN RETURN 0; END;
  3. CREATE FUNCTION fn_annual RETURN DATE AS BEGIN RETURN SYSDATE; END;
  4. Functions cannot access tables
B. Correct function structure: CREATE [OR REPLACE] FUNCTION name(params) RETURN datatype AS/IS ... BEGIN ... RETURN value; END;. Key elements: RETURN clause in header, SELECT INTO to fetch data, RETURN statement to send value back, EXCEPTION handler for robustness. Option A is a procedure (no RETURN), missing INTO. Option C returns wrong type.
🏢 Writing functions from scratch is a standard lab exam question.
L3 — ApplyFunctions
Q8

Write a trigger that prevents deletion of employees with salary > ₹1,00,000.

  1. AFTER DELETE trigger with INSERT
  2. CREATE TRIGGER trg_no_delete BEFORE DELETE ON employees FOR EACH ROW BEGIN IF :OLD.salary > 100000 THEN RAISE_APPLICATION_ERROR(-20010, 'Cannot delete high-salary employee'); END IF; END;
  3. INSTEAD OF DELETE on the table
  4. Triggers cannot prevent operations
B. BEFORE DELETE trigger fires before the row is actually deleted. Check :OLD.salary (the existing row's salary). If condition met, RAISE_APPLICATION_ERROR stops the DELETE and sends error to client. Must be BEFORE (not AFTER — row is already gone). INSTEAD OF is only for views. FOR EACH ROW is needed to access :OLD.
🏢 Business rule enforcement via triggers is a core enterprise PL/SQL pattern.
L3 — ApplyTriggers
Q9

Write a cursor FOR loop that prints all customers from Mumbai with their order count.

  1. Use SELECT INTO
  2. BEGIN FOR rec IN (SELECT c.customer_name, COUNT(o.order_id) AS order_count FROM customers c LEFT JOIN orders o ON c.customer_id=o.customer_id WHERE c.city='Mumbai' GROUP BY c.customer_name) LOOP DBMS_OUTPUT.PUT_LINE(rec.customer_name || ': ' || rec.order_count || ' orders'); END LOOP; END;
  3. Cursors can't use JOINs
  4. FOR loops don't work with cursors
B. Cursor FOR loop with inline SELECT: the query is embedded directly in the FOR statement. Oracle auto-handles OPEN, FETCH, %NOTFOUND check, and CLOSE. The loop variable rec is auto-declared with fields matching the SELECT columns (rec.customer_name, rec.order_count). LEFT JOIN ensures customers with zero orders are included. This is the cleanest, most readable cursor pattern.
🏢 Cursor FOR loop is the industry-standard way to process query results in PL/SQL.
L3 — ApplyCursors
Q10

A trigger on the orders table tries to SELECT COUNT(*) FROM orders inside its body. What happens?

  1. It works normally
  2. ORA-04091: Mutating table error. A row-level trigger cannot query or modify the table that fired it. Fix: use a statement-level trigger, compound trigger, or move the logic to a procedure called after the DML completes
  3. It returns 0
  4. The trigger is ignored
B. Mutating table error is Oracle's protection against inconsistent reads during DML. When a row-level trigger fires, the table is "in flux" — rows are being modified. Reading from it would give unpredictable results. Solutions: (1) Use AFTER STATEMENT trigger instead. (2) Use a compound trigger (Oracle 11g+): collect data in BEFORE EACH ROW, process in AFTER STATEMENT. (3) Use an autonomous transaction (last resort).
🏢 Mutating table is the #1 PL/SQL debugging challenge. Know the solutions.
L4 — AnalyzeTriggers
Q11

An exception occurs in a nested inner block that has no EXCEPTION section. What happens?

  1. The program crashes immediately
  2. The exception propagates to the enclosing outer block. If the outer block has an EXCEPTION handler for that exception type, it catches it. If not, it propagates further until handled or the program terminates with an unhandled exception error
  3. The inner block silently ignores the error
  4. Oracle automatically fixes the error
B. Exception propagation: unhandled exceptions "bubble up" through block nesting. Inner → Outer → Calling procedure → Client application. This is identical to try-catch propagation in Java/Python. Best practice: handle specific exceptions where you can recover, let unexpected ones propagate to WHEN OTHERS at the outermost level for logging.
🏢 Understanding exception propagation is critical for debugging production PL/SQL code.
L4 — AnalyzeExceptions
Q12

A developer uses an explicit cursor with OPEN/FETCH/CLOSE for a simple iteration. A senior DBA suggests using a cursor FOR loop instead. Evaluate the DBA's recommendation.

  1. The DBA is wrong — explicit cursors are always better
  2. The DBA is correct. Cursor FOR loops are superior for simple iterations: auto OPEN/FETCH/CLOSE, no risk of forgetting CLOSE (memory leak), no need for EXIT WHEN %NOTFOUND, auto-declares the loop record variable. Use explicit OPEN/FETCH/CLOSE only when you need fine-grained control (e.g., fetching in batches, conditional exit)
  3. Cursor FOR loops are slower
  4. They produce different results
B. Cursor FOR loop advantages: (1) ~50% less code, (2) no CLOSE leak risk, (3) auto-%NOTFOUND, (4) auto-variable declaration. When to still use explicit: FETCH LIMIT (bulk collect), fetching two rows and comparing, conditional cursor processing. Oracle's own documentation recommends cursor FOR loop as the default pattern.
🏢 Senior PL/SQL developers always prefer cursor FOR loops for standard iterations.
L5 — EvaluateBest Practices
Q13

A team debates putting business logic in triggers vs stored procedures. The logic is: "When an order is placed, check inventory, debit stock, calculate GST, send notification." Evaluate both approaches.

  1. Triggers are always better for business logic
  2. Stored procedure is better for complex multi-step logic. Triggers should be used for simple, automatic side-effects (audit logs, auto-timestamps). Complex logic in triggers is hard to debug, can cause unexpected cascading effects, and is invisible to developers reading the INSERT statement. A procedure makes the logic explicit, testable, and controllable
  3. Neither — use application code only
  4. It doesn't matter
B. Trigger anti-pattern: hiding complex business logic in triggers makes code unpredictable. A simple INSERT INTO orders suddenly checks inventory, sends emails, and modifies 5 other tables — invisible to the developer. Best practice: triggers for SIMPLE auto-actions (set timestamps, log changes). Complex logic in EXPLICIT procedure calls. The calling code should clearly show: sp_place_order() → developer knows complex logic runs.
🏢 This is a senior-level design decision. Companies like Flipkart explicitly ban complex trigger logic.
L5 — EvaluateArchitecture
Q14

Design a PL/SQL package for a library management system with: issue_book, return_book, calculate_fine, and get_member_history.

  1. Use individual procedures without a package
  2. Package specification declares: PROCEDURE issue_book(p_member_id, p_book_id), PROCEDURE return_book(p_member_id, p_book_id, p_fine OUT NUMBER), FUNCTION calculate_fine(p_issue_date DATE, p_return_date DATE) RETURN NUMBER, FUNCTION get_member_history(p_member_id) RETURN SYS_REFCURSOR. Package body implements each with cursors, exception handling, and business rules (max 3 books, ₹2/day late fine, weekday-only logic)
  3. Use triggers for everything
  4. Packages can't contain functions
B. Package design: specification is the public API (what other programs can call). Body contains implementation details (hidden from callers). Advantages: (1) Encapsulation — private helper procedures hidden in body. (2) Session state — package variables persist across calls. (3) Compilation — changing body doesn't recompile dependents. (4) Organized — related procedures grouped logically.
🏢 Package design is the hallmark of professional PL/SQL development. LIC, SBI, and IRCTC all use packages for business modules.
L6 — CreatePackages
Q15

Design a complete audit system using triggers: track all INSERT, UPDATE, DELETE on the accounts table with old/new values, timestamp, and user.

  1. Three separate triggers (one per operation)
  2. One compound trigger or three triggers: CREATE TRIGGER trg_accounts_audit BEFORE INSERT OR UPDATE OR DELETE ON accounts FOR EACH ROW. Use IF INSERTING/UPDATING/DELETING to determine operation type. Log to audit_accounts(audit_id, account_id, operation, old_balance, new_balance, changed_by, changed_at). In INSERTING: :OLD is NULL. In DELETING: :NEW is NULL. In UPDATING: both available
  3. Use application-level logging only
  4. Audit is not possible with triggers
B. Combined trigger handles all three DML types. Oracle provides INSERTING, UPDATING, DELETING boolean functions inside triggers. Complete audit: IF INSERTING THEN INSERT INTO audit(op='INSERT', new_bal=:NEW.balance); ELSIF UPDATING THEN INSERT INTO audit(op='UPDATE', old_bal=:OLD.balance, new_bal=:NEW.balance); ELSIF DELETING THEN INSERT INTO audit(op='DELETE', old_bal=:OLD.balance); END IF;. This is the standard pattern for SOX/RBI compliance audit in Indian banking.
🏢 Audit triggers are mandatory in banking (RBI), healthcare (HIPAA), and finance (SOX) systems.
L6 — CreateAudit
Section 7

Chapter Summary

PL/SQL PROGRAMMING │ ├── BLOCK STRUCTURE │ ├── DECLARE (optional): variables, constants, cursors, exceptions │ ├── BEGIN...END (required): executable statements │ └── EXCEPTION (optional): error handlers │ ├── VARIABLES & TYPES │ ├── Scalar: NUMBER, VARCHAR2, DATE, BOOLEAN │ ├── %TYPE: anchored to column type (best practice) │ ├── %ROWTYPE: entire row structure │ ├── RECORD TYPE: user-defined composite │ └── CONSTANT: immutable values │ ├── CONTROL STRUCTURES │ ├── IF / ELSIF / ELSE / END IF │ ├── CASE expression (searched & simple) │ ├── LOOP / EXIT WHEN / END LOOP │ ├── WHILE loop │ └── FOR i IN 1..n LOOP (and REVERSE) │ ├── CURSORS │ ├── Implicit: auto for DML, access via SQL%FOUND/ROWCOUNT │ ├── Explicit: DECLARE → OPEN → FETCH → CLOSE │ ├── Cursor FOR loop: auto OPEN/FETCH/CLOSE (preferred) │ ├── Parameterized cursors: CURSOR c(p NUMBER) IS ... │ └── FOR UPDATE / WHERE CURRENT OF │ ├── EXCEPTION HANDLING │ ├── Predefined: NO_DATA_FOUND, TOO_MANY_ROWS, ZERO_DIVIDE │ ├── User-defined: DECLARE + RAISE │ ├── RAISE_APPLICATION_ERROR(-20000 to -20999, 'msg') │ ├── WHEN OTHERS: catch-all (use SQLCODE, SQLERRM) │ └── Propagation: inner → outer → caller → client │ ├── PROCEDURES & FUNCTIONS │ ├── Procedure: IN/OUT/IN OUT params, performs actions, no RETURN │ ├── Function: RETURN one value, usable in SQL SELECT │ ├── CREATE OR REPLACE: avoid DROP + CREATE │ └── Parameter modes: IN (read), OUT (write), IN OUT (both) │ ├── TRIGGERS │ ├── BEFORE/AFTER × INSERT/UPDATE/DELETE × ROW/STATEMENT │ ├── :OLD / :NEW pseudo-records (row triggers only) │ ├── INSTEAD OF: enable DML on complex views │ ├── INSERTING/UPDATING/DELETING: detect operation type │ └── Mutating table error: can't query own table in row trigger │ └── PACKAGES ├── Specification: public interface (declarations) ├── Body: private implementation (hidden) ├── Advantages: encapsulation, session state, organized code └── PostgreSQL equivalent: schemas + functions

🎯 3 Skills This Chapter Unlocks

  1. Procedural Database Logic — Variables, loops, conditionals inside the database. Every enterprise application (banking, insurance, telecom) runs business rules as PL/SQL procedures.
  2. Cursor Mastery — Row-by-row processing for complex operations (waitlist processing, invoice generation, data migration) that SQL alone can't express.
  3. Trigger-Based Automation — Automatic audit trails, data validation, and cascading updates. Essential for regulatory compliance (RBI, SEBI, SOX).

📋 PL/SQL Quick Reference

BLOCK:    DECLARE ... BEGIN ... EXCEPTION ... END; /

VARIABLE: v_name datatype [:= value];
ANCHORED: v_name table.column%TYPE;
ROW:      v_rec  table%ROWTYPE;

IF:       IF cond THEN ... ELSIF cond THEN ... ELSE ... END IF;
FOR:      FOR i IN 1..n LOOP ... END LOOP;
WHILE:    WHILE cond LOOP ... END LOOP;
CURSOR:   FOR rec IN (SELECT ...) LOOP ... END LOOP;

PROCEDURE: CREATE OR REPLACE PROCEDURE name(p IN type, p OUT type)
           AS ... BEGIN ... EXCEPTION ... END;
FUNCTION:  CREATE OR REPLACE FUNCTION name(p IN type) RETURN type
           AS ... BEGIN ... RETURN val; END;

TRIGGER:   CREATE TRIGGER name BEFORE|AFTER INSERT|UPDATE|DELETE
           ON table FOR EACH ROW BEGIN ... END;
           Access: :OLD.col, :NEW.col
           Detect: IF INSERTING / UPDATING / DELETING

PACKAGE:   CREATE PACKAGE name AS ... END;
           CREATE PACKAGE BODY name AS ... END;

EXCEPTIONS: NO_DATA_FOUND, TOO_MANY_ROWS, ZERO_DIVIDE, DUP_VAL_ON_INDEX
            RAISE_APPLICATION_ERROR(-20001, 'message');
Section 8

Interview & Career Preparation

Q1: What is PL/SQL? Why is it needed when we have SQL?

Model Answer: PL/SQL (Procedural Language/SQL) extends SQL with procedural constructs — variables, loops, conditionals, exception handling. SQL is declarative (tells WHAT to retrieve), PL/SQL is procedural (tells HOW to process). Needed when: (1) row-by-row processing logic, (2) complex business rules with multiple conditions, (3) transaction control with error recovery, (4) performance — reduces network round trips by sending one procedure call instead of multiple SQL statements.

Q2: Explain the cursor lifecycle.

Model Answer: Four steps: (1) DECLARE: define the cursor with a SELECT query. (2) OPEN: execute the query and populate the result set. (3) FETCH: retrieve one row at a time into variables. (4) CLOSE: release memory and resources. Use %NOTFOUND to exit the fetch loop. Cursor FOR loop automates all four steps — preferred for simple iterations. Use explicit OPEN/FETCH/CLOSE for complex scenarios (batch processing, conditional exit).

Q3: What is the difference between procedure and function?

Model Answer: Function: must RETURN exactly one value, can be used in SQL SELECT/WHERE, primarily for computation. Procedure: no return value (uses OUT parameters), cannot be called in SQL, primarily for performing actions (DML, transactions). Functions should be pure (no side effects) for SQL usage. Both support IN, OUT, IN OUT parameters. Procedures support COMMIT/ROLLBACK; functions should avoid them when called from SQL.

Q4: What is a mutating table error? How do you fix it?

Model Answer: ORA-04091 occurs when a row-level trigger queries or modifies the same table that fired it. The table is "mutating" (being changed), so reading from it gives inconsistent results. Fixes: (1) Compound trigger (Oracle 11g+): collect data in BEFORE EACH ROW, process in AFTER STATEMENT. (2) Use a statement-level trigger instead. (3) Package variable: store row-level data in a PL/SQL collection in BEFORE ROW, process in AFTER STATEMENT. (4) Autonomous transaction (last resort, potential data inconsistency).

Q5: What are :OLD and :NEW in triggers?

Model Answer: Pseudo-records available in row-level triggers. :OLD = column values before the DML. :NEW = column values after the DML. INSERT: :OLD is NULL (no previous row), :NEW has new values. DELETE: :OLD has existing values, :NEW is NULL. UPDATE: both available. Can MODIFY :NEW in BEFORE triggers (e.g., :NEW.updated_at := SYSDATE). Cannot modify :OLD. Use in WHEN clause without colon: WHEN (OLD.salary != NEW.salary).

Q6: What is a PL/SQL package? Why use one?

Model Answer: A package groups related procedures, functions, variables, cursors, and exceptions into a single named unit. Two parts: Specification (public interface — what callers see) and Body (private implementation — hidden). Advantages: (1) Encapsulation — private helpers hidden from outside. (2) Performance — loaded into memory once per session. (3) Maintainability — changing body doesn't recompile dependent programs. (4) Session state — package variables persist across procedure calls in one session. Example: pkg_banking.deposit(), pkg_banking.withdraw().

Q7: How do you handle exceptions in PL/SQL?

Model Answer: EXCEPTION section at the end of a block. Three types: (1) Predefined: NO_DATA_FOUND, TOO_MANY_ROWS, ZERO_DIVIDE — catch by name. (2) User-defined: DECLARE exception variable, RAISE it explicitly. (3) Unnamed: use PRAGMA EXCEPTION_INIT to map Oracle error number to a name. WHEN OTHERS catches everything — use SQLCODE and SQLERRM for details. RAISE re-raises to caller. RAISE_APPLICATION_ERROR sends custom error (-20000 to -20999) to the client application.

Q8: What is RAISE_APPLICATION_ERROR?

Model Answer: A built-in procedure that raises a user-defined error with a custom error number and message. Syntax: RAISE_APPLICATION_ERROR(error_number, message). Error number must be between -20000 and -20999. The error propagates to the calling application (Java, Python, .NET) as an ORA exception. Used to enforce business rules: RAISE_APPLICATION_ERROR(-20001, 'Insufficient balance for withdrawal'). The client can catch this specific error number and display an appropriate message.

Q9: What is the difference between BEFORE and AFTER triggers?

Model Answer: BEFORE trigger fires before the DML executes — use for: validation, auto-populating columns (:NEW.created_at := SYSDATE), preventing invalid operations (RAISE_APPLICATION_ERROR). AFTER trigger fires after the DML completes — use for: audit logging, cascading updates to other tables, sending notifications. Key rule: you can modify :NEW values only in BEFORE triggers, not AFTER (the row is already committed to the buffer).

Q10: How is PL/pgSQL different from Oracle PL/SQL?

Model Answer: PL/pgSQL (PostgreSQL) is very similar but has key differences: (1) Dollar-quoting: $$ ... $$ instead of / for block termination. (2) RAISE NOTICE instead of DBMS_OUTPUT.PUT_LINE. (3) No packages — use schemas + separate functions. (4) RETURNS void for procedures (or CREATE PROCEDURE in PG 11+). (5) Different exception names: SQLSTATE codes instead of ORA numbers. (6) RETURN QUERY for set-returning functions. (7) RECORD instead of %ROWTYPE in some cases. Core logic (blocks, cursors, IF/LOOP) is nearly identical — skills transfer easily.