PL/SQL to PL/pgSQL
PL/SQL is the part of an Oracle schema that most migration plans underestimate. The schema converter handles tables, types, and sequences. The data pipeline handles rows. But PL/SQL — packages, procedures, functions, triggers, and types — encodes business logic that does not appear in the data model and cannot be discovered by reading CREATE TABLE statements.
This chapter is not a PL/SQL-to-PL/pgSQL translation manual. It is a guide to the structural differences that require architectural decisions, the patterns that translate mechanically, the patterns that do not translate at all, and how a product-grade migration handles the boundary between what can be automated and what cannot.
8.1 What PL/SQL Contains
An Oracle schema can contain:
- Packages: collections of related procedures, functions, constants, and type definitions. The most common PL/SQL structure.
- Standalone procedures and functions: callable by applications directly.
- Triggers: event-driven code that fires before/after INSERT, UPDATE, DELETE on specific tables.
- Types: user-defined object types and collection types.
- Anonymous blocks: PL/SQL executed directly (in scripts, jobs, or dynamically). Usually not stored in the schema.
- Database jobs (DBMS_SCHEDULER / DBMS_JOB): scheduled PL/SQL execution.
Each category has a different migration path and different risk profile.
8.2 Oracle Packages → PostgreSQL Schemas and Functions
Oracle packages have two parts: the package specification (the public interface) and the package body (the implementation). The package provides a namespace: pkg_accounts.get_balance(p_account_id) is distinct from pkg_payments.get_balance(p_payment_id).
PostgreSQL has no package concept. The nearest equivalents are:
Schema as namespace: Create a PostgreSQL schema named after the package and put functions in it. pkg_accounts.get_balance becomes the PostgreSQL function pkg_accounts.get_balance(p_account_id BIGINT) in schema pkg_accounts.
-- Oracle package spec
CREATE OR REPLACE PACKAGE pkg_accounts AS
FUNCTION get_balance(p_account_id NUMBER) RETURN NUMBER;
PROCEDURE update_status(p_account_id NUMBER, p_status VARCHAR2);
END pkg_accounts;
-- PostgreSQL equivalent (schema as namespace)
CREATE SCHEMA IF NOT EXISTS pkg_accounts;
CREATE OR REPLACE FUNCTION pkg_accounts.get_balance(p_account_id BIGINT)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
v_balance NUMERIC;
BEGIN
SELECT balance INTO v_balance
FROM account
WHERE account_id = p_account_id;
RETURN v_balance;
END;
$$;
CREATE OR REPLACE PROCEDURE pkg_accounts.update_status(
p_account_id BIGINT,
p_status TEXT
)
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE account SET status = p_status WHERE account_id = p_account_id;
END;
$$;
Package state (package variables): Oracle packages can declare variables in the package specification or body that persist for the lifetime of a session. These are package-level global variables:
-- Oracle package with session state
CREATE OR REPLACE PACKAGE pkg_session AS
g_current_user_id NUMBER;
PROCEDURE set_user(p_user_id NUMBER);
FUNCTION get_user RETURN NUMBER;
END pkg_session;
PostgreSQL has no equivalent session-level state within a function namespace. Options:
- Use PostgreSQL
SET LOCAL/current_setting()to store session state in configuration parameters - Use a session-local temporary table
- Pass the state as explicit function parameters (the cleanest approach)
-- PostgreSQL: using SET/current_setting for session state
CREATE OR REPLACE PROCEDURE pkg_session.set_user(p_user_id BIGINT)
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM set_config('pkg_session.current_user_id', p_user_id::TEXT, false);
-- false = not transaction-local (persists for session)
END;
$$;
CREATE OR REPLACE FUNCTION pkg_session.get_user()
RETURNS BIGINT
LANGUAGE plpgsql
AS $$
BEGIN
RETURN current_setting('pkg_session.current_user_id', true)::BIGINT;
END;
$$;
Product note: Pulsaride Transform inventories all packages using DBA_PROCEDURES and DBA_SOURCE during assessment. Package-level variable declarations are flagged as PACKAGE_STATE findings with a note on the three resolution options. Package procedures and functions are catalogued with their signatures, call counts (from V$SQL sampling if accessible), and dependency relationships. The migration planner uses this inventory to prioritize which packages must be migrated before cutover.
8.3 Exception Handling
Oracle PL/SQL has a structured exception handling model with named exceptions, SQLCODE, SQLERRM, and user-defined exceptions:
-- Oracle exception handling
BEGIN
INSERT INTO account (account_id, email) VALUES (v_id, v_email);
EXCEPTION
WHEN DUP_VAL_ON_INDEX THEN
-- duplicate key
UPDATE account SET email = v_email WHERE account_id = v_id;
WHEN OTHERS THEN
v_error_code := SQLCODE;
v_error_msg := SQLERRM;
INSERT INTO error_log (error_code, error_msg, created_at)
VALUES (v_error_code, v_error_msg, SYSDATE);
RAISE;
END;
PostgreSQL PL/pgSQL uses similar syntax with different exception names:
-- PostgreSQL exception handling
BEGIN
INSERT INTO account (account_id, email) VALUES (v_id, v_email);
EXCEPTION
WHEN unique_violation THEN
-- duplicate key (PostgreSQL equivalent of DUP_VAL_ON_INDEX)
UPDATE account SET email = v_email WHERE account_id = v_id;
WHEN OTHERS THEN
INSERT INTO error_log (error_code, error_msg, created_at)
VALUES (SQLSTATE, SQLERRM, NOW());
RAISE;
END;
Oracle exception name → PostgreSQL exception name mappings:
| Oracle Exception | PostgreSQL Exception |
|---|---|
DUP_VAL_ON_INDEX |
unique_violation |
NO_DATA_FOUND |
no_data_found |
TOO_MANY_ROWS |
too_many_rows |
VALUE_ERROR |
invalid_text_representation or numeric_value_out_of_range |
ZERO_DIVIDE |
division_by_zero |
CURSOR_ALREADY_OPEN |
(not applicable — PostgreSQL cursors work differently) |
INVALID_CURSOR |
(not applicable) |
LOGIN_DENIED |
invalid_password |
SQLCODE in Oracle returns a negative ORA- number or +100 for NO_DATA_FOUND. In PostgreSQL, use SQLSTATE which returns a 5-character code per the SQL standard. Oracle PL/SQL code that branches on specific SQLCODE values (e.g., WHEN SQLCODE = -2291) must be rewritten to use PostgreSQL's SQLSTATE or named exception constants.
8.4 Cursor Patterns
Oracle PL/SQL uses explicit cursors frequently. The three common patterns and their PostgreSQL equivalents:
Pattern 1: Implicit Cursor (SELECT INTO)
-- Oracle
SELECT balance INTO v_balance
FROM account
WHERE account_id = p_id;
-- Raises NO_DATA_FOUND if no row, TOO_MANY_ROWS if more than one
-- PostgreSQL PL/pgSQL — identical syntax
SELECT balance INTO v_balance
FROM account
WHERE account_id = p_id;
-- FOUND is set to false if no row; does not automatically raise
-- To match Oracle behavior:
IF NOT FOUND THEN
RAISE EXCEPTION 'No data found for account_id %', p_id;
END IF;
The behavior difference: Oracle raises NO_DATA_FOUND automatically on a failed SELECT INTO. PostgreSQL sets the FOUND variable to false and continues execution. Code that relies on Oracle's automatic exception raise must be updated to check FOUND explicitly.
Pattern 2: Explicit Cursor FOR Loop
-- Oracle: cursor FOR loop
FOR rec IN (SELECT account_id, balance FROM account WHERE status = 'ACTIVE') LOOP
-- process rec.account_id, rec.balance
UPDATE ledger SET current_balance = rec.balance WHERE account_id = rec.account_id;
END LOOP;
-- PostgreSQL PL/pgSQL: identical syntax
FOR rec IN (SELECT account_id, balance FROM account WHERE status = 'ACTIVE') LOOP
UPDATE ledger SET current_balance = rec.balance WHERE account_id = rec.account_id;
END LOOP;
Cursor FOR loops translate directly. This is one of the areas where Oracle-to-PostgreSQL PL/SQL migration is genuinely easy.
Pattern 3: REF CURSOR
Oracle REF CURSOR allows returning a result set from a function or procedure. PostgreSQL uses RETURNS TABLE(...) or RETURNS REFCURSOR for similar purposes.
-- Oracle procedure returning REF CURSOR
CREATE OR REPLACE PROCEDURE get_active_accounts(
p_cursor OUT SYS_REFCURSOR
) AS
BEGIN
OPEN p_cursor FOR
SELECT account_id, user_name, balance FROM account WHERE status = 'ACTIVE';
END;
-- PostgreSQL equivalent using RETURNS TABLE
CREATE OR REPLACE FUNCTION get_active_accounts()
RETURNS TABLE (account_id BIGINT, user_name TEXT, balance NUMERIC)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT a.account_id, a.user_name, a.balance
FROM account a
WHERE a.status = 'ACTIVE';
END;
$$;
-- Called as:
SELECT * FROM get_active_accounts();
Application code calling the Oracle procedure via JDBC using CallableStatement with registerOutParameter(Types.REF_CURSOR) must be updated to call the PostgreSQL function using a regular SELECT.
8.5 BULK COLLECT and FORALL
BULK COLLECT and FORALL are Oracle PL/SQL extensions for high-performance bulk DML. They operate on collections (arrays) to reduce round-trips between PL/SQL and the SQL engine.
-- Oracle: BULK COLLECT + FORALL
DECLARE
TYPE account_id_list IS TABLE OF NUMBER;
TYPE balance_list IS TABLE OF NUMBER;
v_ids account_id_list;
v_balances balance_list;
BEGIN
SELECT account_id, balance
BULK COLLECT INTO v_ids, v_balances
FROM account
WHERE status = 'ACTIVE';
FORALL i IN 1..v_ids.COUNT
UPDATE ledger
SET current_balance = v_balances(i)
WHERE account_id = v_ids(i);
END;
PostgreSQL PL/pgSQL has no BULK COLLECT or FORALL. The PostgreSQL equivalent depends on what the code is doing:
Option 1: Use a single SQL statement
Most BULK COLLECT + FORALL patterns are doing something that can be expressed as a single SQL DML statement. The above Oracle example is equivalent to:
-- PostgreSQL: single UPDATE...FROM (eliminates PL/pgSQL entirely)
UPDATE ledger l
SET current_balance = a.balance
FROM account a
WHERE l.account_id = a.account_id
AND a.status = 'ACTIVE';
This is the preferred resolution: eliminate the procedural bulk pattern in favor of set-oriented SQL.
Option 2: Use arrays in PL/pgSQL
When the FORALL logic is more complex (conditional processing, error logging per row), use PostgreSQL arrays:
-- PostgreSQL: array-based bulk processing
DECLARE
v_ids BIGINT[];
v_balances NUMERIC[];
BEGIN
SELECT ARRAY_AGG(account_id), ARRAY_AGG(balance)
INTO v_ids, v_balances
FROM account
WHERE status = 'ACTIVE';
-- Process using unnest
UPDATE ledger l
SET current_balance = vals.balance
FROM (SELECT UNNEST(v_ids) AS account_id, UNNEST(v_balances) AS balance) vals
WHERE l.account_id = vals.account_id;
END;
Product note: Pulsaride Transform flags all BULK COLLECT and FORALL occurrences in DBA_SOURCE with a BULK_COLLECT_FORALL finding. For each occurrence, the assessment report includes:
- The procedure/function name and line numbers
- A classification:
SET_OPERATION(can be rewritten as SQL) orCONDITIONAL_BULK(requires array or loop pattern) - A suggested PostgreSQL equivalent for
SET_OPERATIONcases
8.6 Autonomous Transactions
Oracle autonomous transactions (PRAGMA AUTONOMOUS_TRANSACTION) allow a procedure or function to commit independently of the calling transaction. They are commonly used for audit logging: log the attempt even if the main transaction rolls back.
-- Oracle: autonomous transaction for audit
CREATE OR REPLACE PROCEDURE log_audit_event(
p_table_name VARCHAR2,
p_action VARCHAR2,
p_user_id NUMBER
) AS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
INSERT INTO audit_log (table_name, action, user_id, logged_at)
VALUES (p_table_name, p_action, p_user_id, SYSDATE);
COMMIT; -- commits only this autonomous transaction
END;
PostgreSQL has no PRAGMA AUTONOMOUS_TRANSACTION. This is a structural difference with no direct equivalent.
The PostgreSQL alternatives:
Option 1: dblink to a separate connection
The dblink extension allows opening a second connection to the same (or another) database and executing SQL in a separate transaction:
-- PostgreSQL: autonomous transaction via dblink
CREATE OR REPLACE PROCEDURE log_audit_event(
p_table_name TEXT,
p_action TEXT,
p_user_id BIGINT
)
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM dblink_exec(
'dbname=' || current_database(),
format(
'INSERT INTO audit_log (table_name, action, user_id, logged_at)
VALUES (%L, %L, %L, NOW())',
p_table_name, p_action, p_user_id
)
);
END;
$$;
This is transactionally independent but has overhead: it opens a new connection for each call. Acceptable for low-frequency audit logging; not acceptable for high-frequency operations.
Option 2: Redesign to use a separate commit path
The cleanest long-term approach: move audit logging out of the transaction boundary entirely. Use an application-level audit log that writes to a separate table (or message queue) outside the main transaction, or use PostgreSQL's LISTEN/NOTIFY to asynchronously process audit events.
Product note: PRAGMA AUTONOMOUS_TRANSACTION is classified as a HIGH_RISK_MIGRATION finding. It requires a design decision, not a mechanical translation. Pulsaride Transform surfaces all occurrences with context (which operation they log, how frequently they are called) and provides both the dblink option and the redesign option with trade-offs. Autonomous transaction procedures must be reviewed by an architect before migration proceeds.
8.7 Triggers
Oracle triggers are event-driven PL/SQL that fires before or after DML on a table. PostgreSQL supports triggers with very similar semantics.
-- Oracle: BEFORE INSERT trigger
CREATE OR REPLACE TRIGGER account_bi
BEFORE INSERT ON account
FOR EACH ROW
BEGIN
:NEW.created_at := SYSDATE;
:NEW.created_by := SYS_CONTEXT('USERENV', 'SESSION_USER');
IF :NEW.account_id IS NULL THEN
SELECT account_seq.NEXTVAL INTO :NEW.account_id FROM DUAL;
END IF;
END;
-- PostgreSQL equivalent
CREATE OR REPLACE FUNCTION account_bi_fn()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.created_at := NOW();
NEW.created_by := current_user;
IF NEW.account_id IS NULL THEN
NEW.account_id := nextval('account_seq');
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER account_bi
BEFORE INSERT ON account
FOR EACH ROW
EXECUTE FUNCTION account_bi_fn();
The structural difference: PostgreSQL separates the trigger function (a standalone function returning TRIGGER) from the trigger binding (which attaches the function to a table event). Oracle combines them.
Trigger Ordering
Oracle fires triggers in an undefined order when multiple triggers exist on the same table/event. PostgreSQL fires triggers in alphabetical order by trigger name. If Oracle trigger behavior depends on execution order (sharing state through package variables), this is a problem.
Product note: Pulsaride Transform scans for multiple triggers on the same table/event combination and emits a TRIGGER_ORDER_DEPENDENCY warning if they exist. Application behavior that depends on Oracle's undefined (but practically stable) trigger ordering must be explicitly reviewed.
Statement-Level vs Row-Level Triggers
Oracle supports both FOR EACH ROW (row-level) and statement-level triggers. PostgreSQL supports the same. The Oracle WHEN clause (trigger firing condition) translates to PostgreSQL's WHEN clause in the trigger definition:
-- Oracle
CREATE OR REPLACE TRIGGER account_status_au
AFTER UPDATE OF status ON account
FOR EACH ROW
WHEN (OLD.status <> NEW.status)
BEGIN ...
-- PostgreSQL
CREATE TRIGGER account_status_au
AFTER UPDATE OF status ON account
FOR EACH ROW
WHEN (OLD.status <> NEW.status)
EXECUTE FUNCTION account_status_au_fn();
8.8 Database Jobs (Scheduled Procedures)
Oracle's DBMS_SCHEDULER and the older DBMS_JOB run PL/SQL procedures on a schedule. PostgreSQL has no built-in scheduler equivalent. Options:
-
pg_cron extension: A PostgreSQL extension that provides cron-like scheduling within the database. Requires installation and superuser setup.
-
External scheduler: Move the scheduling to the operating system (
cron), KubernetesCronJob, or an application scheduler (Quartz, Spring Batch schedules). Call the PostgreSQL function via a shell script or application process. -
pgAgent: A PostgreSQL job scheduler that runs as a separate agent process.
Product note: Pulsaride Transform inventories all DBMS_SCHEDULER and DBMS_JOB entries during assessment. Each job is documented with: schedule expression, procedure called, last run time, enabled/disabled state, and failure history (from DBA_SCHEDULER_JOB_RUN_DETAILS). The migration team decides on a scheduling infrastructure before cutover. The assessment report includes the pg_cron equivalent expression for each DBMS_SCHEDULER schedule.
8.9 Example: The pkg_billing Migration
A billing system has a package pkg_billing with four procedures: calculate_invoice, apply_payment, generate_statement, and reverse_payment. The package body is 1,200 lines.
Assessment findings:
| Finding | Location | Priority |
|---|---|---|
PACKAGE_STATE |
g_current_period DATE (package variable) |
High |
BULK_COLLECT_FORALL |
calculate_invoice, lines 88–112 |
Medium (SET_OPERATION) |
AUTONOMOUS_TRANSACTION |
apply_payment, line 201 |
High |
DUP_VAL_ON_INDEX |
apply_payment, line 208 |
Low (rename) |
BULK_COLLECT_FORALL |
generate_statement, lines 445–478 |
Medium (CONDITIONAL_BULK) |
Migration plan for pkg_billing:
-
g_current_period: Refactor to pass billing period as an explicit parameter to all four procedures. Eliminates package state. -
BULK COLLECT in
calculate_invoice(lines 88–112): Rewrite as a singleUPDATE ... FROM (SELECT ...)statement. This is the SET_OPERATION pattern — the entire loop is a join update. -
Autonomous transaction in
apply_payment: Route payment application events to a Kafka topic consumed by an audit service. The audit service commits independently. This is the redesign option, chosen becauseapply_paymentis called 15,000 times per day — too frequent for dblink overhead. -
DUP_VAL_ON_INDEX: Rename tounique_violationin PostgreSQL. -
BULK COLLECT in
generate_statement(lines 445–478): Rewrite using PostgreSQL arrays and UNNEST. The loop has conditional logic (different statement line formats based on transaction type) that prevents a pure SQL rewrite.
After migration, the pkg_billing schema in PostgreSQL contains four functions with an average of 180 lines each (down from 300 lines each in Oracle, because the BULK COLLECT pattern expands to many lines in Oracle but collapses to SQL in PostgreSQL). Total PL/pgSQL for this package: ~720 lines, migrated from 1,200 Oracle lines.