The 60 PL/SQL questions interviewers actually ask, with direct answers, runnable code, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
PL/SQL (Procedural Language for SQL) is Oracle's procedural extension to SQL. Where plain SQL states what data you want, PL/SQL adds the how: variables, IF and CASE logic, loops, cursors, and structured error handling, all running inside the Oracle Database engine. Its unit is the block, which has an optional declaration section, a mandatory execution section between BEGIN and END, and an optional exception section for handling errors. Blocks become named, stored objects: procedures, functions, packages, and triggers that live in the database and run close to the data. According to Oracle's PL/SQL Language Reference, the language tightly integrates SQL so a query result flows straight into procedural code without leaving the server. In interviews, PL/SQL questions probe cursors, exception handling, bulk processing (BULK COLLECT and FORALL), and the cost of switching between the SQL and PL/SQL engines. This page collects the 60 questions that come up most, each with a direct answer and runnable code. Increasingly the first database round runs as a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Oracle PL/SQL Full Course
Video: Oracle PL/SQL Full Course (IT Expert, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your PL/SQL certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
PL/SQL is Oracle's procedural extension to SQL. Plain SQL says what data you want; PL/SQL adds the how, with variables, IF and CASE branching, loops, cursors, and structured error handling, so you can express logic the database runs itself. It compiles into stored objects like procedures, functions, packages, and triggers that live in the database.
Oracle adds it so you can keep data-heavy logic close to the data. Instead of pulling every row to an application and pushing changes back, a PL/SQL block runs inside the engine, cutting network trips and keeping business rules in one place.
Key point: A one-line definition plus the 'runs close to the data' reason beats a memorized feature list. Interviewers open with this to hear how you organize an answer.
A block has three sections. The optional declaration section, introduced by DECLARE, holds variables, cursors, and types. The mandatory execution section sits between BEGIN and END and contains the actual statements. The optional exception section, starting at EXCEPTION, catches errors raised anywhere in the execution part and handles them.
Only the execution section is required. The smallest legal block is BEGIN NULL; END; which does nothing.
DECLARE
v_total NUMBER := 0;
BEGIN
v_total := 10 + 5;
DBMS_OUTPUT.PUT_LINE('Total: ' || v_total);
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('Something failed');
END;
/Key point: Interviewers use this to check you know DECLARE and EXCEPTION are optional but the BEGIN...END body is not.
A function must return a single value through a RETURN clause, and because it yields a value it can be called inside a SQL expression or a SELECT list. A procedure instead performs an action and returns nothing directly, though it can hand values back to the caller through OUT parameters. That return requirement is the core split.
Use a function when the goal is to compute and return a value; use a procedure when the goal is to do something, like updating rows or sending a batch.
CREATE OR REPLACE FUNCTION full_name(p_first VARCHAR2, p_last VARCHAR2)
RETURN VARCHAR2 IS
BEGIN
RETURN p_first || ' ' || p_last;
END;
/
CREATE OR REPLACE PROCEDURE raise_salary(p_id NUMBER, p_pct NUMBER) IS
BEGIN
UPDATE employees SET salary = salary * (1 + p_pct/100) WHERE emp_id = p_id;
END;
/| Procedure | Function | |
|---|---|---|
| Returns a value | No (only via OUT) | Yes, mandatory RETURN |
| Usable in SQL SELECT | No | Yes, if it meets purity rules |
| Typical purpose | Perform an action | Compute and return a value |
| Call style | EXEC or in a block | As an expression |
Key point: The follow-up is 'can you call a procedure inside a SELECT?'. the technical answer is no, and knowing why (SELECT needs a value) shows real understanding.
PL/SQL uses all the SQL scalar types (NUMBER, VARCHAR2, DATE, CHAR, CLOB) and adds PL/SQL-only types like BOOLEAN, PLS_INTEGER, and BINARY_INTEGER. On top of scalars it has composite types: records that group fields, collections that hold many elements, and cursor types that point at result sets. That range is what lets one language cover both data and logic.
BOOLEAN and PLS_INTEGER matter in interviews because they're PL/SQL-only: you can't store a BOOLEAN in a table column, and PLS_INTEGER is faster than NUMBER for loop counters because it maps to native machine integers.
Key point: Mentioning that BOOLEAN can't live in a table column is a small detail that indicates hands-on experience.
Declare variables in the DECLARE section as name followed by a type, with an optional starting value after := or the DEFAULT keyword. To make a constant, add the CONSTANT keyword and an initial value; a constant must be initialized at declaration and can't be reassigned anywhere in the block, so the compiler rejects any later write to it.
Add NOT NULL to force a variable to always hold a value; assigning NULL then raises an error at runtime.
DECLARE
v_count NUMBER := 0;
v_name VARCHAR2(50) NOT NULL := 'Asha';
c_tax_pct CONSTANT NUMBER := 18;
BEGIN
DBMS_OUTPUT.PUT_LINE(v_name || ' tax ' || c_tax_pct);
END;
/%TYPE declares a variable using the data type of an existing table column or another variable, written as v_sal employees.salary%TYPE. The variable inherits whatever type and size that column has, so you never restate the type by hand and never risk it drifting out of sync with the schema.
The benefit is that your code stays correct when the column definition changes. Widen salary from NUMBER(6) to NUMBER(8) and every %TYPE variable follows automatically, with no edits.
DECLARE
v_salary employees.salary%TYPE;
BEGIN
SELECT salary INTO v_salary FROM employees WHERE emp_id = 101;
DBMS_OUTPUT.PUT_LINE('Salary: ' || v_salary);
END;
/Key point: Pair %TYPE with %ROWTYPE in your answer. Anchoring variable types to the schema is exactly the maintainability habit interviewers look for.
%ROWTYPE declares a record variable whose fields match every column of a table or the whole select list of a cursor. One variable then holds an entire row, and you reach each field with dot notation like v_emp.salary or v_emp.first_name. It's the row-level counterpart to %TYPE, which anchors a single scalar to one column.
It saves declaring each column separately and stays in sync with the table. Fetching a cursor row straight into a %ROWTYPE record is the tidiest way to loop over query results.
DECLARE
v_emp employees%ROWTYPE;
BEGIN
SELECT * INTO v_emp FROM employees WHERE emp_id = 101;
DBMS_OUTPUT.PUT_LINE(v_emp.first_name || ': ' || v_emp.salary);
END;
/A cursor is a pointer to the result set of a SQL query, letting PL/SQL process rows one at a time. There are two kinds. Implicit cursors are the ones Oracle opens automatically for every single SQL statement you run. Explicit cursors are ones you declare, open, fetch from, and close yourself, which gives you row-by-row control over a multi-row query.
You reach for an explicit cursor when you need to walk many rows and act on each. For a single-row lookup, SELECT INTO with its implicit cursor is simpler and shorter.
DECLARE
CURSOR c_emps IS SELECT first_name, salary FROM employees;
v_name employees.first_name%TYPE;
v_sal employees.salary%TYPE;
BEGIN
OPEN c_emps;
LOOP
FETCH c_emps INTO v_name, v_sal;
EXIT WHEN c_emps%NOTFOUND;
DBMS_OUTPUT.PUT_LINE(v_name || ': ' || v_sal);
END LOOP;
CLOSE c_emps;
END;
/The explicit cursor lifecycle
A cursor FOR loop does all four steps for you, which is why it's the default choice.
Key point: The classic follow-up is 'implicit vs explicit'. Lead with the definition, then The kinds without being asked.
Watch a deeper explanation
Video: PL/SQL tutorial 26: Introduction to PL/SQL Cursor in Oracle Database By Manish Sharma (Manish Sharma, YouTube)
Oracle opens an implicit cursor automatically for every SQL statement you run, including SELECT INTO and every INSERT, UPDATE, and DELETE, then closes it for you. An explicit cursor is one you declare yourself and drive manually through OPEN, FETCH, and CLOSE, which gives you row-by-row control that an implicit cursor doesn't.
Use an explicit cursor when you need to loop over many rows with full control. Implicit cursors handle single-row and DML statements, and you read their outcome through attributes like SQL%ROWCOUNT and SQL%FOUND.
| Implicit cursor | Explicit cursor | |
|---|---|---|
| Who opens it | Oracle, automatically | You, with OPEN |
| Best for | Single-row and DML | Multi-row loops |
| Attribute prefix | SQL% | cursor_name% |
| Control over fetch | None | Full, row by row |
Key point: the key point is that even a plain UPDATE runs through an implicit cursor. That detail separates memorizers from understanders.
These four attributes report a cursor's state. %FOUND is TRUE if the last fetch returned a row; %NOTFOUND is its opposite and drives loop exits; %ROWCOUNT counts rows fetched so far; %ISOPEN tells you whether the cursor is currently open.
For explicit cursors you prefix them with the cursor name (c_emps%NOTFOUND). For the last implicit statement you prefix them with SQL (SQL%ROWCOUNT after an UPDATE tells you how many rows changed).
BEGIN
UPDATE employees SET salary = salary + 100 WHERE dept_id = 10;
DBMS_OUTPUT.PUT_LINE('Rows updated: ' || SQL%ROWCOUNT);
END;
/A cursor FOR loop opens a cursor, fetches each row into a loop record, runs the body once per row, and closes the cursor automatically when the rows run out, all in one compact statement. You skip the manual OPEN, the FETCH, the EXIT WHEN test, and the CLOSE that a hand-written cursor loop needs.
It's preferred because it's shorter, harder to get wrong (no forgotten CLOSE, no infinite loop from a missing EXIT), and reads clearly. The loop variable is an implicit %ROWTYPE record, so you reach columns with dot notation.
BEGIN
FOR emp IN (SELECT first_name, salary FROM employees) LOOP
DBMS_OUTPUT.PUT_LINE(emp.first_name || ': ' || emp.salary);
END LOOP;
END;
/Key point: If you write a manual OPEN/FETCH/CLOSE loop when a cursor FOR loop would do, expect the interviewer to ask why. Reach for the FOR loop by default.
Watch a deeper explanation
Video: PL/SQL tutorial 30: Cursor FOR Loop In Oracle Database By Manish Sharma (Manish Sharma, YouTube)
IF...THEN...ELSIF...ELSE...END IF branches on boolean conditions, evaluated top to bottom until one is TRUE, then runs that branch. CASE comes in two forms: a simple CASE that matches one expression against a list of values, and a searched CASE that evaluates independent boolean conditions the way an IF chain does but more compactly.
CASE also works as an expression that returns a value, which IF can't do. Use IF for control flow and CASE when you're assigning a value based on conditions.
DECLARE
v_score NUMBER := 82;
v_grade CHAR(1);
BEGIN
v_grade := CASE
WHEN v_score >= 90 THEN 'A'
WHEN v_score >= 80 THEN 'B'
ELSE 'C'
END;
DBMS_OUTPUT.PUT_LINE('Grade: ' || v_grade);
END;
/Three main ones. The basic LOOP runs until an explicit EXIT or EXIT WHEN breaks out, so it always executes at least once. The WHILE loop tests a condition before each pass and may run zero times. The numeric FOR loop counts through a fixed low-to-high range. On top of those, the cursor FOR loop iterates the rows of a query.
Pick the basic LOOP when you must run at least once and exit mid-body, WHILE when the condition should be checked first, and the numeric FOR loop when you know the range up front.
BEGIN
FOR i IN 1 .. 3 LOOP
DBMS_OUTPUT.PUT_LINE('Numeric FOR: ' || i);
END LOOP;
FOR i IN REVERSE 1 .. 3 LOOP
DBMS_OUTPUT.PUT_LINE('Reverse: ' || i);
END LOOP;
END;
/SELECT INTO runs a query and stores its single-row result directly in one or more variables, or in a %ROWTYPE record. It's the standard way to fetch exactly one row without the ceremony of declaring, opening, and closing an explicit cursor, so it reads cleanly for primary-key lookups.
Two things go wrong often. If the query returns no rows it raises NO_DATA_FOUND; if it returns more than one row it raises TOO_MANY_ROWS. A reliable SELECT INTO either targets a primary key or handles both exceptions.
DECLARE
v_name employees.first_name%TYPE;
BEGIN
SELECT first_name INTO v_name FROM employees WHERE emp_id = 101;
DBMS_OUTPUT.PUT_LINE(v_name);
EXCEPTION
WHEN NO_DATA_FOUND THEN
DBMS_OUTPUT.PUT_LINE('No employee 101');
WHEN TOO_MANY_ROWS THEN
DBMS_OUTPUT.PUT_LINE('More than one match');
END;
/Key point: NO_DATA_FOUND and TOO_MANY_ROWS is useful because signals you've actually hit these in real code.
When a statement in the execution section raises an error, control immediately jumps to the EXCEPTION section, where WHEN clauses match the raised exception by name and run their handler code. The first matching handler wins, and WHEN OTHERS at the end catches anything not named above it, so nothing escapes unhandled if you want a catch-all.
Exceptions come in three flavors: predefined ones with names (NO_DATA_FOUND, ZERO_DIVIDE), non-predefined Oracle errors you bind to a name with PRAGMA EXCEPTION_INIT, and user-defined exceptions you declare and RAISE yourself.
DECLARE
v_result NUMBER;
BEGIN
v_result := 10 / 0;
EXCEPTION
WHEN ZERO_DIVIDE THEN
DBMS_OUTPUT.PUT_LINE('Cannot divide by zero');
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('Error: ' || SQLERRM);
END;
/Key point: A bare WHEN OTHERS that swallows the error silently is a red flag. Say you'd at least log SQLCODE and SQLERRM before deciding to continue.
Watch a deeper explanation
Video: PL/SQL tutorial 46: Introduction to PL/SQL exception Handling in Oracle Database By Manish Sharma (Manish Sharma, YouTube)
The ones the question expects: NO_DATA_FOUND (a SELECT INTO found no row), TOO_MANY_ROWS (it found more than one), ZERO_DIVIDE (division by zero), INVALID_CURSOR (an illegal cursor operation like fetching from a closed cursor), VALUE_ERROR (a conversion or size error), and DUP_VAL_ON_INDEX (an insert violated a unique constraint).
These have names, so you catch them by name rather than checking error numbers. That readability is the point of predefined exceptions.
| Exception | Fires when |
|---|---|
| NO_DATA_FOUND | SELECT INTO returns zero rows |
| TOO_MANY_ROWS | SELECT INTO returns multiple rows |
| ZERO_DIVIDE | You divide by zero |
| DUP_VAL_ON_INDEX | An insert breaks a unique constraint |
| VALUE_ERROR | A conversion or size limit is violated |
DBMS_OUTPUT is a built-in package that writes text to a per-session buffer, mainly for debugging and simple status messages while you develop a block. PUT_LINE appends one line to that buffer, and the client reads the buffer back after the block finishes, so the messages appear together at the end rather than streaming live.
The output isn't shown automatically. In SQL*Plus or SQLcl you run SET SERVEROUTPUT ON first; in tools like SQL Developer you enable the DBMS Output panel. Forget that step and your PUT_LINE calls seem to do nothing, which trips up beginners constantly.
SET SERVEROUTPUT ON;
BEGIN
DBMS_OUTPUT.PUT_LINE('Hello from PL/SQL');
END;
/Key point: SET SERVEROUTPUT ON matters.
COMMIT makes all changes since the last commit permanent and visible to other sessions. ROLLBACK undoes uncommitted changes back to the last commit or to a named savepoint. SAVEPOINT marks a point you can roll back to without discarding the whole transaction.
In PL/SQL you usually let the caller control the transaction rather than committing inside a procedure, because a commit buried in a subprogram can break a larger unit of work the caller is coordinating.
BEGIN
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT after_debit;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- if the credit fails, undo just it:
-- ROLLBACK TO after_debit;
COMMIT;
END;
/Key point: Saying you avoid committing inside reusable procedures indicates production maturity. The caller owns the transaction boundary.
DELETE is DML: it removes rows one at a time, fires row triggers, respects a WHERE clause, and can be rolled back. TRUNCATE is DDL: it empties the whole table fast by deallocating storage, ignores triggers and WHERE, and commits implicitly so you can't roll it back. DROP is DDL too, but it removes the entire table, its data, and its structure.
In PL/SQL, DELETE runs as a normal statement, but TRUNCATE and DROP are DDL, so you run them through EXECUTE IMMEDIATE. Pick DELETE when you need conditions or rollback, TRUNCATE to clear a table quickly, DROP to remove the object.
| DELETE | TRUNCATE | DROP | |
|---|---|---|---|
| Type | DML | DDL | DDL |
| WHERE clause | Yes | No | No |
| Rollback | Yes | No (auto-commit) | No (auto-commit) |
| Removes structure | No | No | Yes |
Key point: The rollback difference is the point interviewers probe: TRUNCATE and DROP commit implicitly, so there's no undo.
For candidates with working experience: cursors in depth, packages, triggers, and the judgment that separates users from understanders.
A package is a schema object that groups related procedures, functions, types, cursors, and variables under one name, split into two parts. The specification declares the public interface that callers can see, and the body holds the implementation plus any private objects. You call the parts through the package name, like payroll.annual_salary.
Packages give you encapsulation (hide the body, expose the spec), a namespace, and a performance edge: the whole package loads into memory on first call, and you can change the body without invalidating code that depends only on the spec.
CREATE OR REPLACE PACKAGE payroll AS
FUNCTION annual_salary(p_monthly NUMBER) RETURN NUMBER;
END payroll;
/
CREATE OR REPLACE PACKAGE BODY payroll AS
FUNCTION annual_salary(p_monthly NUMBER) RETURN NUMBER IS
BEGIN
RETURN p_monthly * 12;
END;
END payroll;
/Key point: The killer detail: changing the body doesn't invalidate callers that depend only on the spec. That reduced recompilation is a real reason teams package everything.
The specification is the public contract: it declares procedure and function headers, types, constants, and public variables that callers are allowed to see and use. The body contains the actual code for those subprograms plus any private declarations, like helper procedures or internal variables, that callers can't reach at all. The two are compiled separately.
A subprogram declared in the spec must be defined in the body. Anything defined only in the body is private to the package. You can compile a spec alone, which is what lets dependent code compile before the body exists.
| Specification | Body | |
|---|---|---|
| Holds | Public declarations | Implementation plus private objects |
| Visibility | Seen by callers | Hidden from callers |
| Required | Yes | Only if the spec declares subprograms |
| Change impact | Invalidates dependents | Does not invalidate spec dependents |
A trigger is a named PL/SQL block that runs automatically when a defined event happens. The common type is a DML trigger firing on INSERT, UPDATE, or DELETE, but there are also DDL triggers (on CREATE, ALTER) and database event triggers (on LOGON, STARTUP).
DML triggers vary along two axes: timing (BEFORE or AFTER the statement) and granularity (statement-level, firing once, or row-level with FOR EACH ROW, firing per affected row). Row-level triggers get :OLD and :NEW to read the pre- and post-change values.
CREATE OR REPLACE TRIGGER trg_audit_sal
BEFORE UPDATE OF salary ON employees
FOR EACH ROW
BEGIN
IF :NEW.salary < :OLD.salary THEN
RAISE_APPLICATION_ERROR(-20001, 'Salary cannot decrease');
END IF;
END;
/Key point: Explaining BEFORE vs AFTER and statement vs row on the same answer is the bar. The follow-up is usually the mutating table error.
:OLD and :NEW are pseudo-records available inside a row-level trigger that hold the column values before and after the change. On INSERT, :OLD is null and :NEW is the incoming row; on DELETE, :NEW is null and :OLD is the row being removed; on UPDATE both are populated.
In a BEFORE trigger you can assign to :NEW to change what gets written, which is how you default or normalize values. In an AFTER trigger the row is already set, so :NEW is read-only.
CREATE OR REPLACE TRIGGER trg_upper_email
BEFORE INSERT ON employees
FOR EACH ROW
BEGIN
:NEW.email := LOWER(:NEW.email);
END;
/A statement-level trigger fires once per triggering statement, no matter how many rows it touches, and has no access to :OLD or :NEW. A row-level trigger (FOR EACH ROW) fires once for every affected row and can read and, in BEFORE triggers, change :OLD and :NEW.
Use statement-level for logging that a change happened or enforcing table-wide rules; use row-level when the logic depends on each row's values.
| Statement-level | Row-level | |
|---|---|---|
| Fires | Once per statement | Once per affected row |
| Access to :OLD/:NEW | No | Yes |
| Clause | Default | FOR EACH ROW |
| Good for | Audit that a change ran | Per-row validation or defaults |
A mutating table error (ORA-04091) happens when a row-level trigger tries to read or change the very table that fired it, while that table is only partway through the change. Oracle blocks it because the table is in an inconsistent, half-updated state, so any query against it could see wrong or nondeterministic results depending on row order.
The modern fix is a compound trigger, which lets you collect affected keys during the row phase and act on the table in the after-statement phase, when the table is stable. Older code used a package with an array of ids across two triggers. Often the cleanest fix is a constraint or a redesign that doesn't query the mutating table at all.
Key point: Reaching straight for a compound trigger, and mentioning that a constraint may be the real fix, indicates someone who's debugged this in production.
Yes. You declare a cursor with a parameter list, then pass values when you OPEN it (or when you name it in a cursor FOR loop). The same cursor definition runs with different inputs each time, so you avoid declaring several near-duplicate cursors that differ only in a WHERE value.
Parameterized cursors keep queries readable and let you reuse one cursor inside a loop that opens it repeatedly with changing arguments, which is cleaner than concatenating values into the query text.
DECLARE
CURSOR c_by_dept(p_dept NUMBER) IS
SELECT first_name FROM employees WHERE dept_id = p_dept;
BEGIN
FOR emp IN c_by_dept(10) LOOP
DBMS_OUTPUT.PUT_LINE(emp.first_name);
END LOOP;
END;
/FOR UPDATE in a cursor's query locks the selected rows so no other session can change them while you process the cursor. WHERE CURRENT OF cursor_name then targets an UPDATE or DELETE at exactly the row the cursor last fetched, without repeating the key in a WHERE clause.
Together they give a safe read-then-modify loop: lock the rows, walk them, and change each one in place. The lock releases on commit or rollback.
DECLARE
CURSOR c_low IS
SELECT salary FROM employees WHERE salary < 3000 FOR UPDATE;
BEGIN
FOR emp IN c_low LOOP
UPDATE employees SET salary = salary * 1.1
WHERE CURRENT OF c_low;
END LOOP;
COMMIT;
END;
/Key point: Knowing WHERE CURRENT OF requires FOR UPDATE on the cursor is the detail interviewers probe here.
BULK COLLECT fetches many rows from a query into a collection in a single operation instead of one row per fetch. It cuts the number of round trips between the PL/SQL engine and the SQL engine, and those context switches are a big part of row-by-row cost.
For large result sets, fetch in batches with the LIMIT clause so you don't load millions of rows into memory at once. Unbounded BULK COLLECT on a huge table can exhaust process memory.
DECLARE
TYPE name_tab IS TABLE OF employees.first_name%TYPE;
v_names name_tab;
CURSOR c IS SELECT first_name FROM employees;
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO v_names LIMIT 500;
EXIT WHEN v_names.COUNT = 0;
-- process this batch of up to 500
END LOOP;
CLOSE c;
END;
/Key point: Mentioning LIMIT in practice is the difference between textbook knowledge and having watched a job run out of memory.
FORALL sends a batch of DML statements (INSERT, UPDATE, or DELETE) to the SQL engine in one call, driven by a collection. A normal FOR loop running the same DML switches to the SQL engine on every iteration; FORALL does it once for the whole batch.
It's not a loop that runs PL/SQL between statements: it's a single bulk DML instruction. Pair it with BULK COLLECT to read and write in batches, and use SAVE EXCEPTIONS to keep going when some rows fail.
DECLARE
TYPE id_tab IS TABLE OF NUMBER;
v_ids id_tab := id_tab(101, 102, 103);
BEGIN
FORALL i IN 1 .. v_ids.COUNT
UPDATE employees SET salary = salary + 500
WHERE emp_id = v_ids(i);
COMMIT;
END;
/Key point: The trap is calling FORALL a loop. It's a single bulk DML call, and saying so precisely is the signal.
Three. Associative arrays, also called index-by tables, are keyed by an integer or a string and live only in PL/SQL memory, never in a table. Nested tables are unbounded, can be sparse after deletes, and can be stored in a column. VARRAYs have a fixed maximum size, stay dense, keep element order, and can also be stored in a column.
Reach for an associative array as a fast in-memory lookup or for BULK COLLECT targets. Nested tables and VARRAYs matter when you need to persist a collection in a table column.
| Associative array | Nested table | VARRAY | |
|---|---|---|---|
| Index type | Integer or string | Integer | Integer |
| Size | Unbounded | Unbounded | Fixed maximum |
| Stored in a column | No | Yes | Yes |
| Sparse allowed | Yes | Yes (after deletes) | No |
A record is a composite variable that groups several fields, each with its own type, under one name, so it models a single row: you reach fields as v_emp.first_name or v_emp.salary. You can define a record type yourself with TYPE ... IS RECORD, or derive one automatically from a table or cursor with %ROWTYPE.
A record holds one item made of several fields; a collection holds many items of the same type. Combine them (a nested table of records) to hold many rows in memory.
DECLARE
TYPE emp_rec IS RECORD (
name VARCHAR2(50),
sal NUMBER
);
v_emp emp_rec;
BEGIN
v_emp.name := 'Asha';
v_emp.sal := 5000;
DBMS_OUTPUT.PUT_LINE(v_emp.name || ': ' || v_emp.sal);
END;
/A REF CURSOR is a cursor variable: a pointer to a result set that you can assign, pass between subprograms, and return to a client. Unlike a static cursor tied to one query, a ref cursor can point at different queries at runtime.
Use it to return a query result to an application or another procedure, or when the exact query is decided at runtime. SYS_REFCURSOR is the built-in weakly-typed version, so you don't have to declare your own type.
CREATE OR REPLACE PROCEDURE get_emps(p_dept NUMBER, p_cur OUT SYS_REFCURSOR) IS
BEGIN
OPEN p_cur FOR
SELECT first_name, salary FROM employees WHERE dept_id = p_dept;
END;
/Key point: The distinction to state: a normal cursor is bound to one query, a ref cursor is a variable you can point at different queries and hand back to a caller.
Watch a deeper explanation
Video: PL/SQL tutorial 67: PL/SQL Ref Cursors In Oracle Database by Manish Sharma (Manish Sharma, YouTube)
IN passes a value into a subprogram and is read-only inside it, and it's the default mode when you write none. OUT sends a value back to the caller; it starts null inside the subprogram and the caller's variable receives whatever you assign to it. IN OUT does both, passing a value in and returning a possibly changed value out.
Use IN for inputs, OUT for results a procedure produces, and IN OUT when the subprogram both reads and updates a caller's variable, like a running total it adjusts.
| Mode | Direction | Read inside | Write inside |
|---|---|---|---|
| IN | Caller to subprogram | Yes | No |
| OUT | Subprogram to caller | Starts null | Yes |
| IN OUT | Both | Yes | Yes |
RAISE_APPLICATION_ERROR raises a custom error with a number you choose in the range -20000 to -20999 and a message text of your own. The calling application receives it like any Oracle error, so business-rule failures surface cleanly instead of as raw ORA codes.
It's how you turn 'salary can't go negative' into a real, catchable exception with a readable message. Pair it with a user-defined exception when you want callers to catch that specific condition by name.
BEGIN
IF :NEW.salary < 0 THEN
RAISE_APPLICATION_ERROR(-20010, 'Salary must be non-negative');
END IF;
END;
/Declare an exception in the DECLARE section as name EXCEPTION, then trigger it with RAISE name where the condition fails, and catch it in a WHEN name THEN handler. The exception carries no number by default; it's a named flag for a business condition.
For a condition tied to a specific Oracle error number, bind it with PRAGMA EXCEPTION_INIT so a raw ORA error maps to a name you can catch.
DECLARE
insufficient_funds EXCEPTION;
v_balance NUMBER := 50;
BEGIN
IF v_balance < 100 THEN
RAISE insufficient_funds;
END IF;
EXCEPTION
WHEN insufficient_funds THEN
DBMS_OUTPUT.PUT_LINE('Balance too low');
END;
/Inside an exception handler, SQLCODE returns the numeric error code of the exception: 0 when there's no error, a negative ORA number for most errors, and +100 for no-data-found in some contexts. SQLERRM returns the matching human-readable error message text for that code, which is what you'd write to a log to explain what actually went wrong.
Log both when you catch WHEN OTHERS so a swallowed error still leaves a trace. Reraising after logging, or using DBMS_UTILITY.FORMAT_ERROR_BACKTRACE for the line number, keeps you from hiding the real problem.
BEGIN
INSERT INTO employees(emp_id) VALUES (101);
EXCEPTION
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('Code: ' || SQLCODE);
DBMS_OUTPUT.PUT_LINE('Msg: ' || SQLERRM);
END;
/Dynamic SQL is SQL built as a string and run at runtime, for cases where the statement isn't known at compile time: a table name from a variable, DDL like CREATE, or a query assembled from conditions. EXECUTE IMMEDIATE parses and runs that string.
Bind values with USING placeholders rather than concatenating them into the text. Binds are faster (the statement is reused) and safe against SQL injection, which raw concatenation is not.
DECLARE
v_id NUMBER := 101;
v_sal NUMBER;
BEGIN
EXECUTE IMMEDIATE
'SELECT salary FROM employees WHERE emp_id = :id'
INTO v_sal USING v_id;
DBMS_OUTPUT.PUT_LINE(v_sal);
END;
/Key point: The follow-up is always about injection. Say you bind with USING and never concatenate user input into the statement text.
An autonomous transaction is a child transaction that commits or rolls back on its own, fully independent of the main transaction that called it. You mark a subprogram with PRAGMA AUTONOMOUS_TRANSACTION, and from then on its own COMMIT persists even if the caller later rolls everything else back, because the two transactions no longer share a fate.
The classic use is logging: you want an audit or error record to survive even when the main work is rolled back. Overuse causes confusion, so reserve it for genuinely independent side effects.
CREATE OR REPLACE PROCEDURE log_event(p_msg VARCHAR2) IS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
INSERT INTO event_log(msg, logged_at) VALUES (p_msg, SYSTIMESTAMP);
COMMIT; -- commits independently of the caller
END;
/A view is a stored query you select from as if it were a table: it packages a SELECT for reuse, hides join complexity, and can restrict which columns and rows a user sees, but it holds no procedural logic. A stored procedure holds the logic instead: loops, conditions, DML, and error handling that a view simply can't express.
Use a view to simplify or secure a read; use a procedure when you need to do something with steps, branching, or transaction control. They're complementary, not competing.
| View | Stored procedure | |
|---|---|---|
| Contains | A stored SELECT | Procedural logic |
| Used in a query | Yes, like a table | No |
| Can run DML with logic | No | Yes |
| Best for | Reusable reads, security | Multi-step operations |
advanced rounds probe performance, internals, and production scars. Expect every answer here to draw a follow-up.
PL/SQL code runs in the PL/SQL engine, but every SQL statement is handed to the SQL engine to execute. Each hand-off is a context switch with real overhead. A loop that runs one SQL statement per row pays that cost on every iteration.
This is why bulk processing wins. BULK COLLECT reads many rows in one switch and FORALL writes many in one switch, so a job that did ten thousand switches drops to a handful. On large volumes the difference is minutes versus seconds.
Row-by-row loop vs BULK COLLECT + FORALL: context switches
SQL-to-PL/SQL context switches to process N = 10,000 rows. Row-by-row switches once per row; bulk processes in one operation per direction.
Key point: This concept underlies half the tuning questions. If you can explain it clearly, BULK COLLECT and FORALL stop being memorized tricks.
Measure first. Use DBMS_PROFILER or the hierarchical DBMS_HPROF to see where time goes, and check EXPLAIN PLAN and SQL trace for the SQL inside the code, because the slow part is usually a query, not the procedural logic. Guessing at bottlenecks is usually wrong.
Then fix in order of impact: replace row-by-row loops with BULK COLLECT and FORALL, fix bad SQL and missing indexes, avoid unnecessary commits inside loops, and cut redundant SQL-to-PL/SQL switches. Rewriting logic as a single set-based SQL statement often beats any PL/SQL tuning.
Key point: the check is for the discipline (measure, don't guess) and that you know pure set-based SQL often beats any PL/SQL.
A compound trigger is a single trigger with up to four timing sections in one body: BEFORE STATEMENT, BEFORE EACH ROW, AFTER EACH ROW, and AFTER STATEMENT, sharing a common state area. It replaces the old pattern of coordinating several separate triggers through a package.
Its main job is dodging the mutating table error: collect affected keys in the row sections into a collection, then act on the table in AFTER STATEMENT, when the table is stable. One object, shared state, no package juggling.
CREATE OR REPLACE TRIGGER trg_comp
FOR UPDATE OF salary ON employees
COMPOUND TRIGGER
TYPE id_tab IS TABLE OF employees.emp_id%TYPE;
g_ids id_tab := id_tab();
AFTER EACH ROW IS
BEGIN
g_ids.EXTEND;
g_ids(g_ids.COUNT) := :NEW.emp_id;
END AFTER EACH ROW;
AFTER STATEMENT IS
BEGIN
-- safe to query employees here
NULL;
END AFTER STATEMENT;
END trg_comp;
/Key point: Naming the four timing sections and connecting them to the mutating table fix is the senior-level answer here.
By default a stored subprogram runs with definer's rights: it uses the privileges and schema of the user who created it, so every caller accesses the same owner's objects. Marking it AUTHID CURRENT_USER makes it run with invoker's rights, using the caller's privileges and name resolution.
Definer's rights centralize access (callers don't need direct table grants). Invoker's rights suit code that should act on each caller's own schema objects, like a shared utility that operates on whatever tables the current user owns.
| Definer's rights | Invoker's rights | |
|---|---|---|
| Default | Yes | No (AUTHID CURRENT_USER) |
| Runs as | The owner | The caller |
| Object resolution | Owner's schema | Caller's schema |
| Good for | Controlled central access | Shared code on caller's objects |
PRAGMA EXCEPTION_INIT binds a named exception you declare to a specific Oracle error number. After the binding, you can catch that raw ORA error by a readable name in a WHEN clause, instead of falling into WHEN OTHERS and testing SQLCODE by hand. The code indicates intent (WHEN child_record_exists) rather than as a magic negative number.
It's how you handle non-predefined Oracle errors gracefully. For example, bind ORA-02292 (child record found on delete) to a name like child_exists and catch it directly.
DECLARE
child_record_exists EXCEPTION;
PRAGMA EXCEPTION_INIT(child_record_exists, -2292);
BEGIN
DELETE FROM departments WHERE dept_id = 10;
EXCEPTION
WHEN child_record_exists THEN
DBMS_OUTPUT.PUT_LINE('Department still has employees');
END;
/By default PL/SQL compiles to bytecode (MCODE) that the PL/SQL runtime interprets at execution time. Native compilation instead translates the code into machine code the CPU runs directly, controlled by the PLSQL_CODE_TYPE setting, which you set to INTERPRETED or NATIVE per session or for the whole database.
Native compilation speeds up procedural-heavy code (loops, arithmetic, computation) but does little for code dominated by SQL, since the SQL executes the same way either way. So it helps compute-bound packages more than data-bound ones.
Marking a function RESULT_CACHE stores its return value keyed by its arguments in a shared, server-side cache that all sessions can read. Later calls with the same arguments return the cached result without re-running the body, and Oracle automatically invalidates the affected entries whenever a table the function depends on changes, so callers never see a stale value.
It fits pure, read-mostly functions whose inputs repeat: reference lookups, expensive derivations over slow-changing tables. It's the wrong tool for functions with side effects or fast-changing data, where the invalidation churn cancels the benefit.
CREATE OR REPLACE FUNCTION dept_name(p_id NUMBER)
RETURN VARCHAR2
RESULT_CACHE IS
v_name departments.name%TYPE;
BEGIN
SELECT name INTO v_name FROM departments WHERE dept_id = p_id;
RETURN v_name;
END;
/A pipelined function returns rows one at a time as they're produced, using PIPE ROW, instead of building the whole collection first and returning it at the end. Callers query it with the TABLE() operator as if it were a table, and rows stream to the consumer immediately.
It suits transforming or generating large row sets inside a query pipeline without buffering everything in memory, and it plays well with parallel query. The consumer starts getting rows before the function finishes.
CREATE OR REPLACE FUNCTION gen_nums(p_n NUMBER)
RETURN sys.odcinumberlist PIPELINED IS
BEGIN
FOR i IN 1 .. p_n LOOP
PIPE ROW(i);
END LOOP;
RETURN;
END;
/
-- SELECT * FROM TABLE(gen_nums(5));For OUT and IN OUT parameters, PL/SQL normally passes by value: it copies the argument in, works on the copy, and copies it back on a clean return. NOCOPY asks the compiler to pass by reference instead, skipping the copies.
It matters for large collections or records, where copying is expensive. The catch: with NOCOPY, a partial change followed by an unhandled exception can leave the caller's variable half-modified, because there was no safe copy to discard. It's a hint, so the compiler may ignore it.
Key point: The trade-off is the whole answer: NOCOPY saves copying big parameters but loses the rollback-on-exception safety of pass-by-value.
A bind variable is a placeholder whose value is supplied at execution, so the SQL text stays identical across calls. Oracle parses and plans a statement once and reuses that plan for every value, instead of hard-parsing a new statement for each literal.
That reuse cuts parsing overhead and library cache contention on high-frequency statements. Binds also stop SQL injection, because the value can never change the statement's structure. Concatenating literals into dynamic SQL breaks both benefits.
-- reuses one parsed statement, injection-safe
EXECUTE IMMEDIATE
'UPDATE employees SET salary = :s WHERE emp_id = :id'
USING v_new_sal, v_id;Key point: Connecting binds to both plan reuse and injection safety in one answer is what a senior interviewer wants to hear.
Normally a FORALL stops at the very first element that raises an error and rolls back the whole bulk statement. Adding the SAVE EXCEPTIONS clause changes that: FORALL keeps processing every remaining element, quietly collects each failure as it goes, and then raises a single ORA-24381 at the end to tell you some rows failed.
You read the collected errors from the SQL%BULK_EXCEPTIONS collection, which holds the index and error code of each failed element. That lets a batch load skip and log bad rows instead of aborting on the first one.
BEGIN
FORALL i IN 1 .. v_ids.COUNT SAVE EXCEPTIONS
INSERT INTO target VALUES (v_ids(i));
EXCEPTION
WHEN OTHERS THEN
FOR j IN 1 .. SQL%BULK_EXCEPTIONS.COUNT LOOP
DBMS_OUTPUT.PUT_LINE('Row ' || SQL%BULK_EXCEPTIONS(j).ERROR_INDEX ||
' failed: ' || SQL%BULK_EXCEPTIONS(j).ERROR_CODE);
END LOOP;
END;
/DETERMINISTIC tells Oracle a function always returns the same output for the same inputs, with no dependence on session state, time, or table data that changes. That promise lets Oracle cache and reuse results in some contexts, like function-based indexes and materialized views.
It's a contract you make, not something Oracle verifies. Mark a function DETERMINISTIC when it truly is pure; do it wrongly on a function that reads changing data and you get stale, incorrect results from cached values.
Variables declared at package level (in the spec or body, outside any subprogram) hold their values for the life of a session, not just one call. That persistent package state can act as a session cache, but it also carries surprises.
Two bite in production: changing a package's spec or body invalidates that state and raises ORA-04068 on the next call in existing sessions, and stale cached values persist across unrelated calls in the same session. Pragma SERIALLY_REUSABLE resets state per call for stateless designs.
Key point: Mentioning ORA-04068 after a recompile, and that connection pooling makes session-level state risky, indicates real operational experience.
Frequent commits inside a loop add overhead (each commit forces a redo log flush) and, worse, break restartability: if the job fails halfway, some rows are committed and some aren't, so a rerun double-processes or skips work. It can also trigger ORA-01555 snapshot-too-old on long cursors.
The better pattern is one commit per logical unit of work, often the whole batch, or periodic commits every N thousand rows only when a single transaction is genuinely too large. Bulk processing usually removes the temptation entirely.
Key point: The restartability point matters more than the performance point. Saying 'partial commits make failed jobs hard to rerun safely' is The production-ready answer.
The methods to know: COUNT gives the number of elements, FIRST and LAST give the lowest and highest indexes, NEXT and PRIOR handle from one index to the next, EXISTS checks whether an index is present, EXTEND grows a nested table or VARRAY, and DELETE and TRIM remove elements. They turn a raw collection into something you can safely iterate.
A collection is sparse when its indexes have gaps, usually after DELETE. That's why you loop with FIRST/NEXT rather than 1..COUNT on a possibly-sparse collection: a plain numeric loop hits a deleted index and raises NO_DATA_FOUND.
DECLARE
TYPE num_tab IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
v num_tab;
i PLS_INTEGER;
BEGIN
v(1) := 10; v(5) := 50; v(9) := 90; -- sparse
i := v.FIRST;
WHILE i IS NOT NULL LOOP
DBMS_OUTPUT.PUT_LINE(i || ' => ' || v(i));
i := v.NEXT(i);
END LOOP;
END;
/Key point: Explaining why FIRST/NEXT beats 1..COUNT on a sparse collection is the trap-aware answer the technical value is.
Prefer static SQL whenever the statement is known at compile time. Static SQL is checked at compile time (invalid columns fail early), records dependencies so recompilation is automatic, and is easier to read and tune. Dynamic SQL loses all of that.
Use dynamic SQL only when you genuinely can't write the statement statically: unknown table or column names, DDL, or optional filters assembled at runtime. Even then, bind values with USING rather than concatenating them, for plan reuse and injection safety.
| Static SQL | Dynamic SQL | |
|---|---|---|
| Checked at compile time | Yes | No, at runtime |
| Dependency tracking | Automatic | None |
| Injection risk | None | If you concatenate input |
| Use when | Statement is known | Statement built at runtime |
Overloading means declaring several subprograms with the same name but different parameter lists, so callers use one name and Oracle picks the version that matches the arguments. It's allowed inside a package or a block, not for standalone stored procedures and functions at schema level.
The versions must differ in the number, type, or family of parameters; you can't overload on return type alone or on parameter names. Overloading built-in-style helpers (a print that accepts a NUMBER or a VARCHAR2) keeps the caller-facing API small.
CREATE OR REPLACE PACKAGE fmt AS
FUNCTION show(p IN NUMBER) RETURN VARCHAR2;
FUNCTION show(p IN DATE) RETURN VARCHAR2;
END fmt;
/Key point: Naming the restriction (no overloading on return type, and not for schema-level standalone subprograms) is what separates a full answer from a partial one.
The modern tool is DBMS_SCHEDULER, which creates named jobs that run a PL/SQL block, a stored procedure, or an external script on a calendar or interval schedule. It replaces the older, thinner DBMS_JOB package, adding run logging, job classes for prioritization, scheduling windows, and dependency chains that let one job trigger another when it finishes.
You define the job with an action, a start time, and a repeat_interval expressed in a calendaring syntax like FREQ=DAILY. The scheduler then runs it, records success or failure, and retries per your settings, so batch work runs without an external cron.
BEGIN
DBMS_SCHEDULER.CREATE_JOB(
job_name => 'nightly_rollup',
job_type => 'PLSQL_BLOCK',
job_action => 'BEGIN run_rollup; END;',
start_date => SYSTIMESTAMP,
repeat_interval => 'FREQ=DAILY; BYHOUR=2',
enabled => TRUE);
END;
/The core rule is simple: never concatenate user input into a SQL string. Pass data values through bind variables with the USING clause of EXECUTE IMMEDIATE, so the input can only ever be treated as a value, never as part of the statement's structure. That single habit closes the door on the most common injection path in dynamic PL/SQL.
When the dynamic part is an identifier (a table or column name that can't be bound), validate it against a known allow-list or pass it through DBMS_ASSERT, which checks that a string is a legal, safe identifier. Prefer static SQL wherever the statement is fixed, since static SQL can't be injected at all.
-- unsafe: input becomes part of the statement
-- EXECUTE IMMEDIATE 'SELECT ... WHERE name = ''' || p_name || '''';
-- safe: input is a bound value
EXECUTE IMMEDIATE
'SELECT emp_id FROM employees WHERE name = :n'
INTO v_id USING p_name;Key point: Bind values, allow-list or DBMS_ASSERT identifiers, prefer static SQL. Hitting all three is the senior-level answer.
Clarify first (source, target, transform, failure tolerance, restartability), then design around bulk processing and set-based work. Read with BULK COLLECT and a LIMIT so memory stays bounded, transform in memory, and write with FORALL plus SAVE EXCEPTIONS so bad rows are logged rather than aborting the run.
Commit per batch, not per row, and track progress (last processed key) so a failed job restarts where it stopped. Push whatever you can into a single set-based SQL statement, because one INSERT SELECT usually beats any procedural loop. The structure, clarify, bulk read, transform, bulk write, restartable commits, is what the question scores.
DECLARE
CURSOR c IS SELECT * FROM staging;
TYPE t IS TABLE OF staging%ROWTYPE;
rows t;
BEGIN
OPEN c;
LOOP
FETCH c BULK COLLECT INTO rows LIMIT 5000;
EXIT WHEN rows.COUNT = 0;
FORALL i IN 1 .. rows.COUNT SAVE EXCEPTIONS
INSERT INTO target VALUES rows(i);
COMMIT; -- one commit per batch
END LOOP;
CLOSE c;
END;
/Key point: The The technical sequence is and the restartability thinking more than any single keyword.
PL/SQL isn't a replacement for SQL: it wraps SQL in procedural logic so you can loop, branch, and handle errors on the server instead of shuttling every row to an application. Compared with its procedural cousins, PL/SQL is Oracle-specific, T-SQL is Microsoft SQL Server's dialect, and PL/pgSQL is PostgreSQL's. They share the same idea (procedural code stored in the database) but differ in syntax, error handling, and which vendor's engine they run on. Naming these trade-offs out loud is itself an interview signal: it shows you know PL/SQL is one option among several, tied to Oracle.
| Feature | SQL | PL/SQL | T-SQL (SQL Server) | PL/pgSQL (PostgreSQL) |
|---|---|---|---|---|
| Nature | Declarative query language | Procedural extension to SQL | Procedural extension to SQL | Procedural extension to SQL |
| Vendor | ANSI standard, all vendors | Oracle only | Microsoft SQL Server | PostgreSQL |
| Code unit | Single statement | Block, procedure, package | Batch, stored procedure | Function, DO block |
| Error handling | None built in | EXCEPTION section | TRY...CATCH | EXCEPTION block |
| Runs where | Database engine | Oracle DB engine | SQL Server engine | PostgreSQL engine |
Prepare in layers, and practice out loud. Most PL/SQL rounds move from concept questions to writing a procedure or cursor to a debugging or tuning discussion, so rehearse each stage rather than only reading answers.
The typical PL/SQL interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These PL/SQL questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works
Q18. How do you write comments in PL/SQL?
Two styles. A double hyphen starts a single-line comment that runs to the end of the line, good for short notes beside a statement. A slash-star to star-slash pair wraps a block comment that can span many lines, good for a header explaining what a procedure does. Both are ignored by the compiler.
Block comments don't nest, so wrapping a section that already contains one closes early. Keep comments explaining why, not restating what the code obviously does.