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
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.
Learning Outcomes — Bloom's Taxonomy
| Bloom's Level | Outcome Statement |
|---|---|
| L1 — Remember | List the three sections of a PL/SQL block; recall cursor types; name predefined Oracle exceptions |
| L2 — Understand | Explain the difference between implicit and explicit cursors; describe how exception propagation works; distinguish procedures from functions |
| L3 — Apply | Write PL/SQL blocks with variables, loops, cursors, and exception handlers; create stored procedures, functions, and triggers for business logic |
| L4 — Analyze | Debug PL/SQL code with cursor attribute checks; analyze trigger execution order (BEFORE/AFTER, ROW/STATEMENT); trace exception propagation through nested blocks |
| L5 — Evaluate | Evaluate when to use procedures vs functions vs triggers; justify cursor FOR loop over explicit OPEN/FETCH/CLOSE; assess trigger design for audit systems |
| L6 — Create | Design a complete PL/SQL package for a banking system with procedures, functions, cursors, exceptions, and triggers working together |
Concept Explanations
3.1 PL/SQL Block Structure
📌 The PL/SQL Block — Your First Program
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%
| Section | Keyword | Required? | Purpose |
|---|---|---|---|
| Declaration | DECLARE | Optional | Define variables, constants, cursors, user-defined exceptions |
| Executable | BEGIN...END | Required | SQL statements, PL/SQL logic, procedure calls |
| Exception | EXCEPTION | Optional | Handle runtime errors gracefully |
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;
/
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
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 ANALOGYA 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
| Attribute | Explicit Cursor | Implicit Cursor (SQL%) |
|---|---|---|
%FOUND | TRUE if last FETCH returned a row | TRUE if last DML affected ≥1 row |
%NOTFOUND | TRUE if last FETCH returned no row | TRUE if last DML affected 0 rows |
%ROWCOUNT | Number of rows fetched so far | Number of rows affected by last DML |
%ISOPEN | TRUE if cursor is currently open | Always 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 Name | ORA Code | When It Fires |
|---|---|---|
NO_DATA_FOUND | ORA-01403 | SELECT INTO returns zero rows |
TOO_MANY_ROWS | ORA-01422 | SELECT INTO returns >1 row |
ZERO_DIVIDE | ORA-01476 | Division by zero |
VALUE_ERROR | ORA-06502 | Arithmetic/conversion/truncation error |
INVALID_CURSOR | ORA-01001 | Cursor operation on unopened cursor |
DUP_VAL_ON_INDEX | ORA-00001 | Unique constraint violation on INSERT |
CURSOR_ALREADY_OPEN | ORA-06511 | Opening 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
| Feature | Procedure | Function |
|---|---|---|
| RETURN value | No (uses OUT parameters) | Yes — exactly ONE return value |
| Use in SQL | ❌ Cannot call in SELECT | ✅ Can call in SELECT, WHERE, etc. |
| Purpose | Perform 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 |
| Parameters | IN, OUT, IN OUT | Mostly 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
| Timing | Type | Fires | Use Case |
|---|---|---|---|
BEFORE | ROW | Before each row is modified | Validation, auto-populate fields, prevent invalid data |
BEFORE | STATEMENT | Once before the DML begins | Check permissions, set session variables |
AFTER | ROW | After each row is modified | Audit logging, cascading updates, notifications |
AFTER | STATEMENT | Once after the DML completes | Summary recalculation, batch logging |
INSTEAD OF | ROW (views only) | Replaces the DML on a view | Enable DML on complex views with JOINs |
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;
/
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;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;
/
Lab Exercises
Exercise 1: PL/SQL Blocks & Control Structures
Tasks:
- Write a block that takes a student's marks and prints grade (A+/A/B+/B/C/F) using IF-ELSIF
- Rewrite using CASE expression
- Print multiplication table (1-10) of a given number using FOR loop
- Find factorial of a number using WHILE loop
- 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
Tasks:
- Explicit cursor: Loop through all products, print those with price > ₹5000
- Cursor FOR loop: Generate an order summary report (customer, product, quantity, total)
- Parameterized cursor: Accept category_id as parameter, display products in that category
- Cursor with FOR UPDATE: Give 10% salary raise to all employees in a department, print old → new salary
- Print cursor attributes (%ROWCOUNT, %FOUND) at each iteration
Exercise 3: Stored Procedures & Functions
Tasks:
- Create procedure
sp_add_customerwith IN parameters (name, city, phone) and OUT parameter (new customer_id) - Create function
fn_get_order_totalthat accepts order_id and returns the sum of (quantity × unit_price) - Create function
fn_age_in_yearsthat accepts a DATE and returns age in years — use in SELECT - Create procedure
sp_update_stockwith IN OUT parameter: pass current stock, deduct quantity, return new stock - Handle NO_DATA_FOUND, TOO_MANY_ROWS, and a custom exception in one procedure
Exercise 4: Triggers — Audit & Business Rules
Tasks:
- BEFORE INSERT trigger: Auto-set
created_atandcreated_byon orders table - AFTER UPDATE trigger: Log salary changes to
salary_audittable with old/new values - BEFORE DELETE trigger: Prevent deletion of orders with status 'DELIVERED' (raise error)
- BEFORE INSERT trigger: Ensure product price is always > 0 (modify :NEW.price if negative)
- Create the audit log table and verify all triggers work with test DML statements
Exercise 5: Complete Banking Package
Tasks: Create a complete pkg_banking package:
- Specification: declare deposit, withdraw, transfer procedures; get_balance, get_mini_statement functions
- Body: implement all procedures with proper exception handling and transaction control
- get_mini_statement: use a cursor to return last 5 transactions
- Add a trigger on accounts table: log every balance change to audit table
- Test: deposit ₹50,000, withdraw ₹20,000, transfer ₹10,000 to another account, print mini-statement
MCQ Assessment Bank — 15 Questions
Hover to reveal answer and explanation.
Which section of a PL/SQL block is mandatory?
- DECLARE
- BEGIN...END
- EXCEPTION
- All three are mandatory
BEGIN NULL; END;.🏢 Basic structure question — expected in every PL/SQL interview.
What does %TYPE do in a PL/SQL variable declaration?
- Creates a new data type
- Anchors the variable's data type to a specific table column — if the column type changes, the variable adapts automatically
- Converts data types at runtime
- Defines a constant
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.
Which predefined exception is raised when SELECT INTO returns zero rows?
- TOO_MANY_ROWS
- NO_DATA_FOUND
- VALUE_ERROR
- INVALID_CURSOR
🏢 NO_DATA_FOUND is the #1 most commonly encountered PL/SQL exception in production.
What is the difference between an implicit cursor and an explicit cursor?
- They are identical
- 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
- Implicit cursors are faster
- Explicit cursors can only be used in functions
🏢 Cursor lifecycle (DECLARE → OPEN → FETCH → CLOSE) is a fundamental PL/SQL concept.
What is the difference between a stored procedure and a function?
- They are identical
- 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
- Procedures are faster
- Functions cannot have parameters
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.
What does :OLD and :NEW refer to in a trigger?
- Old and new tables
- :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
- They are variable prefixes
- They refer to old and new databases
:NEW.updated_at := SYSDATE).🏢 Understanding :OLD/:NEW is essential for audit triggers — used in every enterprise application.
Write a PL/SQL function that accepts an employee_id and returns annual salary (monthly_salary × 12).
CREATE PROCEDURE fn_annual(p_id NUMBER) AS BEGIN SELECT salary*12 FROM emp WHERE emp_id=p_id; END;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;CREATE FUNCTION fn_annual RETURN DATE AS BEGIN RETURN SYSDATE; END;- Functions cannot access tables
🏢 Writing functions from scratch is a standard lab exam question.
Write a trigger that prevents deletion of employees with salary > ₹1,00,000.
- AFTER DELETE trigger with INSERT
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;- INSTEAD OF DELETE on the table
- Triggers cannot prevent operations
🏢 Business rule enforcement via triggers is a core enterprise PL/SQL pattern.
Write a cursor FOR loop that prints all customers from Mumbai with their order count.
- Use SELECT INTO
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;- Cursors can't use JOINs
- FOR loops don't work with cursors
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.
A trigger on the orders table tries to SELECT COUNT(*) FROM orders inside its body. What happens?
- It works normally
- 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
- It returns 0
- The trigger is ignored
🏢 Mutating table is the #1 PL/SQL debugging challenge. Know the solutions.
An exception occurs in a nested inner block that has no EXCEPTION section. What happens?
- The program crashes immediately
- 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
- The inner block silently ignores the error
- Oracle automatically fixes the error
🏢 Understanding exception propagation is critical for debugging production PL/SQL code.
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.
- The DBA is wrong — explicit cursors are always better
- 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)
- Cursor FOR loops are slower
- They produce different results
🏢 Senior PL/SQL developers always prefer cursor FOR loops for standard iterations.
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.
- Triggers are always better for business logic
- 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
- Neither — use application code only
- It doesn't matter
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.
Design a PL/SQL package for a library management system with: issue_book, return_book, calculate_fine, and get_member_history.
- Use individual procedures without a package
- 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)
- Use triggers for everything
- Packages can't contain functions
🏢 Package design is the hallmark of professional PL/SQL development. LIC, SBI, and IRCTC all use packages for business modules.
Design a complete audit system using triggers: track all INSERT, UPDATE, DELETE on the accounts table with old/new values, timestamp, and user.
- Three separate triggers (one per operation)
- 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
- Use application-level logging only
- Audit is not possible with triggers
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.
Chapter Summary
🎯 3 Skills This Chapter Unlocks
- Procedural Database Logic — Variables, loops, conditionals inside the database. Every enterprise application (banking, insurance, telecom) runs business rules as PL/SQL procedures.
- Cursor Mastery — Row-by-row processing for complex operations (waitlist processing, invoice generation, data migration) that SQL alone can't express.
- 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');
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.