Full transfer
The data moves easily; the business logic buried in packages, triggers, and stored procedures is the real project. A pathway for auditing and porting PL/SQL surface area before you commit to a cutover date.
Moving tables from Oracle to PostgreSQL is a solved problem. The risk lives in the PL/SQL that accumulated over the database's life: packages, triggers, and procedures that encode rules no one has written down. Porting the schema without auditing that surface area produces a database that holds the right rows and applies the wrong logic.
Before any DDL is translated, inventory the executable surface: how many packages, how many procedures and functions, how many triggers, and how many lines of PL/SQL depend on Oracle-specific constructs (sequences, hierarchical queries, autonomous transactions, package-level state) that have no direct PostgreSQL equivalent.
| Oracle construct | PostgreSQL path | Effort |
|---|---|---|
| Sequences + triggers for IDs | GENERATED ... AS IDENTITY | Low |
CONNECT BY hierarchies | Recursive CTE | Medium |
| Package-level state | Refactor — no equivalent | High |
| Autonomous transactions | Redesign (dblink / rethink) | High |
An automated converter will translate perhaps 70% of straightforward PL/SQL. The remaining 30% — the package state and the autonomous transactions — is exactly the logic your core operations depend on. Porting it under test is the fixed-scope bridge we deliver before a full cutover is scheduled.
Estimate your PL/SQL risk →Each ported procedure needs a behavioural test built from the Oracle original's actual outputs, so equivalence is proven rather than assumed. This is slower up front and far cheaper than discovering a logic divergence in production after the source system is gone.
A working piece from this integration — no sign-up. The full build handles the edge cases, safeguards, and cutover.
Audit your Oracle PL/SQL surface area before estimating migration effort:
-- Oracle: inventory all PL/SQL objects with line counts SELECT OBJECT_TYPE, OBJECT_NAME, COUNT(*) AS statements, MAX(LINE) AS total_lines FROM USER_SOURCE WHERE OBJECT_TYPE IN ( 'PACKAGE', 'PACKAGE BODY', 'PROCEDURE', 'FUNCTION', 'TRIGGER' ) GROUP BY OBJECT_TYPE, OBJECT_NAME ORDER BY MAX(LINE) DESC; -- Equivalent PostgreSQL query to check pgsql function inventory SELECT routine_type, routine_name, pg_get_functiondef(oid) AS definition FROM pg_proc WHERE pronamespace = 'public'::regnamespace AND routine_type IN ('FUNCTION', 'PROCEDURE'); -- Syntax side-by-side for a simple row-update procedure /* Oracle PL/SQL */ CREATE OR REPLACE PROCEDURE raise_salary( p_emp_id IN employees.employee_id%TYPE, p_increase IN NUMBER ) IS BEGIN UPDATE employees SET salary = salary + p_increase, modified_dt = SYSDATE WHERE employee_id = p_emp_id; IF SQL%ROWCOUNT = 0 THEN RAISE_APPLICATION_ERROR(-20001, 'Not found'); END IF; END raise_salary; / /* PostgreSQL PL/pgSQL */ CREATE OR REPLACE PROCEDURE raise_salary( p_emp_id INTEGER, p_increase NUMERIC ) LANGUAGE plpgsql AS $$ BEGIN UPDATE employees SET salary = salary + p_increase, modified_dt = CURRENT_TIMESTAMP WHERE employee_id = p_emp_id; IF NOT FOUND THEN RAISE EXCEPTION 'Not found'; END IF; END; $$;
How we'd take this from discovery to a production-safe cutover — the phases, the canonical mapping, and the edge cases that bite.
Data migration is the easy part — mechanical, well-tooled, reversible. The genuine engineering challenge is porting PL/SQL business logic: packages, triggers, stored procedures, and the implicit contracts they encode. This pathway structures that port in phases that surface risk early, validate translation accuracy incrementally, and preserve the ability to roll back to Oracle until cutover is proven.
USER_DEPENDENCIES and ALL_OBJECTS in Oracle to map every package, procedure, function, trigger, sequence, and synonym. Classify each by complexity: stateless query wrappers (low risk), multi-statement logic with cursors (medium), packages with global variables or session state (high). Build a dependency graph so you migrate leaf objects before their consumers. Output this as a plsql_inventory.csv with columns: object_name, object_type, line_count, complexity_tier, depends_on.ora2pg (or aws_dms with PL/SQL transformation rules, or the EnterpriseDB migration toolkit) against the inventory from phase 1. Set TYPE = PACKAGE, TYPE = PROCEDURE, TYPE = FUNCTION individually — do not bulk-export everything at once. Each run produces a .sql output and a .report file indicating lines that require manual review. Target: an automated translation pass for every tier-low object, reviewed and committed to a feature branch in your migration schema.FUNCTIONs (or PROCEDUREs in PG 11+). Preserve original namespacing: PKG_ORDERS.CalculateTotals() becomes orders.calculate_totals(). Handle package-level global variables by converting to SET_config() session parameters or by passing state explicitly — document the decision in the migration log.BULK COLLECT/FORALL → FOREACH ... IN ARRAY with UNNEST(); AUTONOMOUS_TRANSACTION → dblink or NOTIFY via pg_notify(); CONNECT BY → recursive WITH RECURSIVE CTEs; SYS_REFCURSOR → REFCURSOR with explicit RETURN clause. Exception blocks shift from WHEN OTHERS THEN to WHEN OTHERS THEN RAISE or explicit GET STACKED DIAGNOSTICS. Each rewritten function gets a new row in plsql_inventory with translated = TRUE and translator = 'manual'.:NEW/:OLD must be rewritten as PostgreSQL CREATE TRIGGER statements using NEW.* and OLD.* in transition tables (REFERENCING NEW TABLE AS new_rows). BEFORE triggers that modify :NEW values become INSTEAD OF triggers on views or BEFORE row triggers using NEW.* := assignments. Validate that firing order (Oracle's FOLLOWS/PRECEDES) maps correctly — PostgreSQL supports FOLLOWS from version 9.0 onward.BEFORE INSERT triggers become PostgreSQL GENERATED ALWAYS AS IDENTITY columns or explicit nextval('seq_name') calls. If sequences drive surrogate keys consumed by application code, validate that CURRVAL semantics (Oracle) map to lastval() (session-scoped in PG) or that you pass the new key explicitly in returning clauses using RETURNING id INTO.pg schema alongside the existing Oracle schema. Replay a curated set of production transaction traces (captured via Oracle AWR or flashback query) against both systems. Compare outputs row-by-row using MINUS on both sides. Every discrepancy generates a bug ticket. Target: zero critical discrepancies in tier-high objects before proceeding.ora2pg --type COPY for initial load, then BDR or pglogical for delta sync). Run your full regression suite against the replica. Profile with pg_stat_statements and compare query plans — sequential scans introduced by different cost models often indicate missing indexes that Oracle's optimizer avoided.pg_stat_replication lag, exception logs in both systems, and application error rates. Only after stability metrics are met does the team flip the primary connection string.| Oracle Construct | PostgreSQL Equivalent | Key Identifier / Note |
|---|---|---|
CREATE PACKAGE / PACKAGE BODY |
CREATE SCHEMA + standalone FUNCTIONs |
Package state lost; use SET_config() for session globals |
CREATE SEQUENCE + NEXTVAL |
GENERATED ALWAYS AS IDENTITY or nextval('seq') |
Sequence caching behavior differs; test with currval() scope |
:NEW / :OLD in row triggers |
NEW.* / OLD.* with transition tables |
REFERENCING NEW TABLE AS nt for statement-level access |
NVL(expr, default) |
COALESCE(expr, default) |
Handles more than two arguments; ANSI-compliant |
SYSDATE |
CURRENT_TIMESTAMP |
Oracle SYSDATE is session timezone; PG uses database timezone |
BULK COLLECT INTO |
INTO ARRAY[... ] or FOREACH over cursor |
No native FORALL; simulate with INSERT ... SELECT UNNEST() |
CONNECT BY / PRIOR |
WITH RECURSIVE CTE |
Rewrite hierarchical queries; Oracle's LEVEL → explicit counter |
DUAL table |
DUAL (PG 9.1+ supports it) or SELECT ...; directly |
PostgreSQL does not require FROM DUAL for expressions |
AUTONOMOUS_TRANSACTION for audit logging inside committed transactions, the pglogical layer or a notification queue (pg_notify) must replace it; failing to do so silently changes audit semantics.SEARCH_PATH adjustments; unresolved synonyms cause runtime undefined_table errors.CREATE TYPE objects with methods cannot be directly migrated. Map to PostgreSQL CREATE TYPE (composite) and separate functions, or handle in application layer.EXECUTE IMMEDIATE with bind variable syntax may translate incorrectly if the Oracle code uses positional USING clauses with mixed types; validate generated PREPARE/EXECUTE statements for type coercion correctness.DATE has second precision; PostgreSQL TIMESTAMP has microsecond. Applications casting DATE to string with an implicit format may mis-parse dates after migration.Make the cutover reversible by retaining the Oracle schema in read-only mode for a minimum of two full business cycles after the flip. Use a shadow comparison: run identical transaction sets against both databases for 48 hours post-cutover, diffing results via an automated MINUS query suite against a reconciliation table. If the delta exceeds zero on any critical object, the rollback procedure is: update the connection string in the application's secrets manager (e.g., Vault, AWS Parameter Store) to point back to Oracle, re-enable Oracle writes, and discard the PostgreSQL replica — the application layer never writes to both simultaneously. Only after the reconciliation period passes without discrepancies does the team decommission the Oracle objects and delete the migration schema from PostgreSQL. Schedule the final Oracle drop during a low-traffic window and retain a final logical export (pg_dump of the migration schema) as a point-in-time artifact for 90 days.
Reading the reference is free. Delivering it under liability — with the safeguards that keep production running through the cutover — is what we do.