Migration

PL/SQL to PL/pgSQL: The Complete Conversion Guide (Part 1)

Rakesh Mamidala·Founder & Lead Engineer··10 min read

The Big Picture — Most of PL/SQL Maps Cleanly

After running 500+ migrations, the pattern is consistent. PL/SQL on a real enterprise codebase splits roughly 80 / 15 / 5: 80% is plain ANSI SQL that moves with no changes, 15% is Oracle-specific but mechanically convertible — type names, function names, syntax variants — and 5% is genuinely vendor-specific and needs human attention (packages with persistent state, autonomous transactions, deep DBMS_* dependencies).

This guide walks through the 15% — the constructs every migration project will hit. Part 1 covers the constructs you encounter on every block of PL/SQL: type mappings, variables, built-in functions, cursors, conditionals, loops, exceptions, and DBMS_OUTPUT. Part 2 will cover the harder layer — packages, autonomous transactions, advanced bulk operations, triggers, and object types.

Every example is paste-able into a transpiler test. The point isn't memorisation — it's building the mental model that PL/SQL and PL/pgSQL are the same language with different spellings for the constructs we cover here.

Type Mappings — The Foundation

Every PL/SQL block starts with type declarations. Most of them map directly; a few have nuances worth knowing.

OraclePostgreSQLNote
NUMBER(p, 0), p ≤ 4SMALLINT−32 768 to 32 767
NUMBER(p, 0), p ≤ 9INTEGERfits in 4 bytes
NUMBER(p, 0), p ≤ 18BIGINTfits in 8 bytes
NUMBER(p, s)NUMERIC(p, s)arbitrary precision
VARCHAR2(n)VARCHAR(n)BYTE / CHAR semantics drop
CHAR(n)CHAR(n)space-padded — same
CLOBTEXTno length limit either side
BLOB / RAWBYTEAbinary; PG stores inline
DATETIMESTAMPOracle DATE includes time
TIMESTAMP WITH TIME ZONETIMESTAMPTZnormalised to UTC internally
XMLTYPEJSONB (or XML)rewrite XPath queries; JSONB has stronger indexing

The single biggest gotcha here is DATE. Oracle DATE holds date and time; PostgreSQL DATE holds only date. The right mapping is to TIMESTAMP, not DATE. Every modern transpiler does this automatically, but hand-written conversions miss it constantly and produce silent precision loss.

Variables, %TYPE, and Defaults

Variable declarations are nearly identical. The few differences are syntactic: PL/pgSQL uses DEFAULT or := interchangeably (PL/SQL accepts both too), and %TYPE / %ROWTYPEwork the same way.

sql
-- Oracle PL/SQL
DECLARE
  v_emp_id    employees.emp_id%TYPE;
  v_salary    NUMBER(10, 2) := 0;
  v_hire_date DATE          DEFAULT SYSDATE;
  v_record    employees%ROWTYPE;
BEGIN
  -- ...
END;

-- PostgreSQL PL/pgSQL — only SYSDATE → NOW() differs
DECLARE
  v_emp_id    employees.emp_id%TYPE;
  v_salary    NUMERIC(10, 2) := 0;
  v_hire_date TIMESTAMP      DEFAULT NOW();
  v_record    employees%ROWTYPE;
BEGIN
  -- ...
END;

Built-in Function Mappings

The functions you call inside every block. Most are 1:1; a few need rewriting.

OraclePostgreSQLSemantics
SYSDATENOW()transaction start time; identical in 99% of cases
SYSTIMESTAMPCLOCK_TIMESTAMP()wall clock; advances during a transaction
NVL(x, y)COALESCE(x, y)ANSI standard form
NVL2(x, y, z)CASE WHEN x IS NOT NULL THEN y ELSE z ENDno direct equivalent
DECODE(x, a, b, c, d, e)CASE x WHEN a THEN b WHEN c THEN d ELSE e ENDmechanical rewrite
TO_CHAR / TO_DATE / TO_NUMBERTO_CHAR / TO_DATE / TO_NUMBERPG has them — format strings nearly identical
INSTR(s, sub, start, n)STRPOS / regexp_instrSTRPOS is the simple case; PG's INSTR is in extensions
SUBSTR(s, p, n)SUBSTRING(s, p, n)note: PG SUBSTR(s, p, n) also works
LENGTH(s)LENGTH(s)identical
LISTAGG(c, sep)STRING_AGG(c, sep)aggregate; identical semantics
ROWNUMROW_NUMBER() OVER ()requires the windowing rewrite
seq.NEXTVALnextval('seq')sequence call; PG quotes the name
SYS_GUID()gen_random_uuid()requires pgcrypto in older PG, native in 13+

Cursors — Three Forms, All Supported

PL/pgSQL supports every cursor pattern PL/SQL uses. The syntax is nearly identical.

sql
-- Oracle: explicit cursor + FETCH loop
DECLARE
  CURSOR c_emp IS SELECT emp_id, last_name FROM employees WHERE dept_id = 10;
  v_id   employees.emp_id%TYPE;
  v_name employees.last_name%TYPE;
BEGIN
  OPEN c_emp;
  LOOP
    FETCH c_emp INTO v_id, v_name;
    EXIT WHEN c_emp%NOTFOUND;
    DBMS_OUTPUT.PUT_LINE(v_id || ' ' || v_name);
  END LOOP;
  CLOSE c_emp;
END;

-- PostgreSQL: same structure, %NOTFOUND becomes NOT FOUND
DO $$
DECLARE
  c_emp  CURSOR FOR SELECT emp_id, last_name FROM employees WHERE dept_id = 10;
  v_id   employees.emp_id%TYPE;
  v_name employees.last_name%TYPE;
BEGIN
  OPEN c_emp;
  LOOP
    FETCH c_emp INTO v_id, v_name;
    EXIT WHEN NOT FOUND;
    RAISE NOTICE '% %', v_id, v_name;
  END LOOP;
  CLOSE c_emp;
END $$;

The cursor FOR loop is also identical: FOR rec IN c_emp LOOP ... END LOOP; works on both sides. The FOR-IN-SELECT shortcut (no explicit cursor declaration) is identical too. There's almost nothing to learn here.

Conditionals — Almost Identical

IF / ELSIF / ELSE / END IF is identical. CASE has two forms — the statement form (executes a branch) and the expression form (returns a value). Both DBs have both; just watch the closing keyword.

sql
-- Identical on both sides:
IF v_score > 90 THEN
  v_grade := 'A';
ELSIF v_score > 80 THEN
  v_grade := 'B';
ELSE
  v_grade := 'C';
END IF;

-- Oracle CASE statement closes with END CASE
CASE v_status
  WHEN 'NEW'    THEN v_priority := 1;
  WHEN 'ACTIVE' THEN v_priority := 2;
  ELSE                v_priority := 99;
END CASE;

-- PostgreSQL CASE statement: identical, also ends with END CASE

Loops and FORALL

LOOP / EXIT WHEN / FOR / WHILE are all identical on both sides. The interesting one is FORALL: in Oracle it's a bulk-DML optimisation; in PostgreSQL the same effect is achieved by a single SQL statement (PG's optimiser already batches under the hood) so the transpiler often collapses FORALL i IN 1..n DO UPDATE ... into one UPDATE ... WHERE id IN (...).

sql
-- Oracle: FORALL for bulk-update of an array
DECLARE
  TYPE id_array IS TABLE OF NUMBER INDEX BY PLS_INTEGER;
  v_ids id_array;
BEGIN
  -- populate v_ids ...
  FORALL i IN 1 .. v_ids.COUNT
    UPDATE orders SET status = 'PROCESSED' WHERE order_id = v_ids(i);
END;

-- PostgreSQL: collapse to a single set-based UPDATE
DO $$
DECLARE
  v_ids INT[];
BEGIN
  -- populate v_ids ...
  UPDATE orders SET status = 'PROCESSED' WHERE order_id = ANY(v_ids);
END $$;

Exceptions — The Named-Exception Mapping

The EXCEPTION block syntax is identical on both sides. The named exceptions need translation, and RAISE_APPLICATION_ERROR becomes RAISE EXCEPTION:

OraclePostgreSQL
NO_DATA_FOUNDNO_DATA_FOUND (same)
TOO_MANY_ROWSTOO_MANY_ROWS (same)
DUP_VAL_ON_INDEXunique_violation
INVALID_NUMBERinvalid_text_representation
ZERO_DIVIDEdivision_by_zero
VALUE_ERRORnumeric_value_out_of_range / string_data_right_truncation
RAISE_APPLICATION_ERROR(-20001, msg)RAISE EXCEPTION '%', msg
PRAGMA EXCEPTION_INIT(e_my, -20055)Define exception with custom SQLSTATE, raise with USING ERRCODE
sql
-- Oracle
BEGIN
  SELECT salary INTO v_salary FROM employees WHERE emp_id = p_id;
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    RAISE_APPLICATION_ERROR(-20001, 'Employee not found: ' || p_id);
  WHEN OTHERS THEN
    RAISE_APPLICATION_ERROR(-20999, 'Unexpected: ' || SQLERRM);
END;

-- PostgreSQL — almost identical, just the RAISE syntax differs
BEGIN
  SELECT salary INTO STRICT v_salary FROM employees WHERE emp_id = p_id;
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    RAISE EXCEPTION 'Employee not found: %', p_id;
  WHEN OTHERS THEN
    RAISE EXCEPTION 'Unexpected: %', SQLERRM;
END;

Note the SELECT INTO STRICT — without STRICT, PostgreSQL silently returns NULL on no-rows, instead of raising NO_DATA_FOUND. This is the single most common subtle bug in hand-converted code. Always use STRICT when porting from Oracle SELECT INTO.

DBMS_OUTPUT.PUT_LINE → RAISE NOTICE

Both are debug-style logging. Oracle's DBMS_OUTPUT buffers output until the block ends and then flushes; PostgreSQL's RAISE NOTICE writes immediately to the client. For most use cases this difference doesn't matter, but it does affect ordering inside long-running blocks.

sql
-- Oracle
DBMS_OUTPUT.PUT_LINE('Processing employee ' || v_id || ', salary $' || v_salary);

-- PostgreSQL
RAISE NOTICE 'Processing employee %, salary $%', v_id, v_salary;

What's Coming in Part 2

Part 2 covers the harder 5% — the constructs that need design decisions, not just spelling changes:

  • Packages — no native PostgreSQL equivalent; the standard pattern is one schema per package, with related functions inside
  • Autonomous transactions — PostgreSQL doesn't have PRAGMA AUTONOMOUS_TRANSACTION directly; the dblink workaround pattern
  • BULK COLLECT semantics — PL/pgSQL supports the syntax but performance characteristics differ; when to keep it, when to rewrite
  • Triggers — Oracle BEFORE / AFTER ROW triggers vs PG's trigger-functions model; the boilerplate that surrounds your business logic
  • Object types and methods — Oracle's OO surface and how to translate to PG composite types + functions

The Honest Verdict

Most PL/SQL is more portable than the team thinks. The constructs in this article — type declarations, variables, cursors, conditionals, loops, exceptions, output — cover the vast majority of every enterprise codebase, and the conversion rules are mechanical enough that modern transpilers clear them with no human input.

The five percent that still needs human attention isn't hidden — a good transpiler finds every instance, classifies it, and tells you upfront whether each one is a one-line fix or a redesign. That visibility is what makes the migration timeline honest.

Try the conversion on your own PL/SQL — 60 seconds

DBMigrateAIPro's free in-browser schema converter accepts pasted PL/SQL and returns the PostgreSQL version side-by-side with a per-construct risk report. No signup, no data leaves your machine.