PostgreSQL

Oracle to PostgreSQL Migration: A Practical DBA Playbook

Migrate Oracle to PostgreSQL using ora2pg. Covers NUMBER→NUMERIC, DATE gotchas, PL/SQL→PL/pgSQL conversion, sequences, DUAL, NULL handling, and validation steps.

JusDB Team
March 5, 2026
Updated May 31, 2026
12 min read
192 views

Oracle licensing costs have quietly become one of the largest line items in enterprise infrastructure budgets — and for many teams, the database workload does not justify the premium. PostgreSQL has matured to the point where it matches or exceeds Oracle in a wide range of transactional and analytical workloads, and the migration path is well-understood. This guide is a practical playbook for DBAs and engineers planning or executing an Oracle-to-PostgreSQL migration, covering schema translation, PL/SQL conversion, tooling, and validation.

TL;DR
  • Oracle Enterprise Edition costs $25,000+ per CPU core; PostgreSQL is free and open source with comparable features for most workloads
  • Key schema differences: NUMBER vs NUMERIC, Oracle DATE includes a time component (use TIMESTAMP in PG), and empty string '' is NULL in Oracle but not in PostgreSQL
  • ora2pg is the standard open-source migration tool — it exports schema, data, sequences, views, and PL/SQL objects with an automated complexity score
  • PL/SQL to PL/pgSQL conversion requires attention to COMMIT/ROLLBACK semantics, IN/OUT parameters, and exception handling syntax
  • Always run a full regression suite and row-count validation before cutover — silent data mismatches from NULL/empty-string differences are the most common post-migration bug

Why Migrate from Oracle to PostgreSQL?

The business case for migrating off Oracle is straightforward. Oracle Database Enterprise Edition is priced at $25,000 per CPU core per year, with additional mandatory licensing for features like partitioning, advanced compression, and RAC. A 4-socket server with 16 cores can easily reach $400,000 per year in license fees alone, before support contracts. PostgreSQL carries zero license cost.

Cloud economics amplify the gap further. Oracle RDS on AWS costs 80–95% more than equivalent Aurora PostgreSQL or Cloud SQL instances. Managed PostgreSQL services — Aurora PostgreSQL, Neon, Supabase, Cloud SQL — offer comparable availability SLAs without proprietary lock-in.

PostgreSQL 17 and the upcoming PG18 now match or exceed Oracle in many benchmark categories: parallel query performance, partitioned table handling, logical replication throughput, and full-text search. The ecosystem around PostgreSQL — extensions like pg_partman, TimescaleDB, pgvector, PostGIS — adds capabilities that Oracle charges separately for. There is no proprietary lock-in: standard SQL, open standards, and a rich community of tooling.

Key Schema and SQL Compatibility Differences

NUMBER vs NUMERIC

Oracle's NUMBER type is flexible and covers integers, decimals, and variable-precision values. PostgreSQL maps these differently depending on precision and scale.

sql
-- Oracle
amount  NUMBER(15,2)
user_id NUMBER(10)   -- often used as integer

-- PostgreSQL equivalents
amount  NUMERIC(15,2)  -- or DECIMAL(15,2)
user_id INTEGER        -- or BIGINT for large tables
-- NUMBER without precision in Oracle = NUMERIC in PG (variable precision)

The practical rule: if an Oracle NUMBER column always holds integers, map it to INTEGER or BIGINT. If it holds decimals with fixed scale, use NUMERIC(p,s). If it holds variable-precision numeric values (rare), use PostgreSQL's plain NUMERIC.

DATE Type: The Biggest Gotcha

Oracle's DATE type includes a time component — it is effectively equivalent to PostgreSQL's TIMESTAMP. This is the single most common source of silent data corruption during migration.

sql
-- Oracle DATE includes TIME component (equivalent to PG TIMESTAMP)
SELECT SYSDATE FROM dual;  -- includes hours/minutes/seconds

-- PostgreSQL DATE is date-only
-- Migration rule: Oracle DATE  →  PostgreSQL TIMESTAMP or TIMESTAMPTZ
-- Migration rule: Oracle TIMESTAMP  →  PostgreSQL TIMESTAMPTZ

-- Example correction
created_at  DATE          -- Oracle (stores time)
created_at  TIMESTAMPTZ   -- PostgreSQL equivalent (with timezone)
Timezone handling: If your Oracle database stores timestamps in a specific timezone or relies on SESSIONTIMEZONE, audit all date/time columns carefully before migration. Migrating Oracle DATE columns as PostgreSQL DATE (date-only) will silently truncate the time component from every row.

NULL Handling and String Concatenation

Oracle treats empty string '' as NULL. This is a longstanding Oracle quirk with no equivalent in the SQL standard, and it does not apply in PostgreSQL.

sql
-- Oracle: empty string '' is NULL (critical quirk)
SELECT * FROM users WHERE name = '';  -- never returns rows in Oracle
-- In PostgreSQL: '' is NOT NULL
SELECT * FROM users WHERE name = '';  -- returns rows with empty string

-- Oracle NVL → PostgreSQL COALESCE
SELECT NVL(phone, 'N/A') FROM users;      -- Oracle
SELECT COALESCE(phone, 'N/A') FROM users; -- PostgreSQL

-- String concatenation: identical in both (|| operator works in both)
SELECT first_name || ' ' || last_name FROM users;  -- works in Oracle and PG
Oracle's empty string = NULL behavior is the most common source of post-migration bugs. Search all application code for WHERE column = '' comparisons and translate them to WHERE column = '' OR column IS NULL before cutover.

ROWNUM vs LIMIT/OFFSET

Oracle uses the pseudocolumn ROWNUM for row limiting; PostgreSQL uses standard SQL LIMIT and OFFSET.

sql
-- Oracle
SELECT * FROM orders WHERE ROWNUM <= 10;
SELECT * FROM (SELECT * FROM orders ORDER BY id) WHERE ROWNUM <= 10;

-- PostgreSQL
SELECT * FROM orders LIMIT 10;
SELECT * FROM orders ORDER BY id LIMIT 10;

DUAL Table

Oracle requires selecting from DUAL for expressions that do not need a real table. PostgreSQL does not have or need DUAL.

sql
-- Oracle
SELECT SYSDATE FROM dual;
SELECT myseq.NEXTVAL FROM dual;

-- PostgreSQL (no DUAL needed)
SELECT NOW();
SELECT nextval('myseq');

-- If migrating Oracle application code that references DUAL, create a compatibility view
CREATE VIEW dual AS SELECT 'X'::TEXT AS dummy;

Sequences and IDENTITY Columns

Oracle uses explicit SEQUENCE objects with .NEXTVAL calls. PostgreSQL supports both traditional sequences and the modern GENERATED ALWAYS AS IDENTITY syntax.

sql
-- Oracle
CREATE SEQUENCE order_seq START WITH 1 INCREMENT BY 1;
CREATE TABLE orders (
  id NUMBER DEFAULT order_seq.NEXTVAL PRIMARY KEY
);

-- PostgreSQL (modern — preferred)
CREATE TABLE orders (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);

-- Or SERIAL for backwards compatibility
CREATE TABLE orders (id BIGSERIAL PRIMARY KEY);

-- If migrating Oracle sequences explicitly
CREATE SEQUENCE order_seq START WITH 1 INCREMENT BY 1;
-- Then use: DEFAULT nextval('order_seq')

Migrating with ora2pg

ora2pg is the most widely used open-source tool for Oracle-to-PostgreSQL schema and data migration. It connects to Oracle via DBD::Oracle, exports schema objects and data, and optionally converts PL/SQL to PL/pgSQL automatically.

Step 1: Install ora2pg

bash
# Install ora2pg on Debian/Ubuntu
sudo apt-get install ora2pg

# On RHEL/CentOS:
sudo yum install ora2pg

# Verify install
ora2pg --version

Step 2: Configure the Connection

bash
cat > /etc/ora2pg/ora2pg.conf << 'EOF'
ORACLE_DSN  dbi:Oracle:host=oracle-host;sid=MYDB;port=1521
ORACLE_USER myuser
ORACLE_PWD  mypassword
SCHEMA      MYSCHEMA
TYPE        TABLE
PG_DSN      dbi:Pg:dbname=myapp;host=localhost;port=5432
PG_USER     pguser
PG_PWD      pgpassword
OUTPUT_DIR  /tmp/ora2pg_output
EOF

Step 3: Assess Migration Complexity

Before exporting anything, run the complexity assessment. This generates a migration cost report with an estimated number of hours of manual work required.

bash
ora2pg --type SHOW_REPORT --estimate_cost
ora2pg's complexity score estimates hours of manual work required. A score of 0–10 means straightforward migration; 10–50 requires significant PL/SQL conversion work; 50+ means complex packages and may require professional migration services.

Step 4: Export Schema Objects

bash
# Export individual object types
ora2pg --type TABLE     --output /tmp/ora2pg_output/tables.sql
ora2pg --type SEQUENCE  --output /tmp/ora2pg_output/sequences.sql
ora2pg --type INDEX     --output /tmp/ora2pg_output/indexes.sql
ora2pg --type VIEW      --output /tmp/ora2pg_output/views.sql
ora2pg --type PROCEDURE --output /tmp/ora2pg_output/procedures.sql
ora2pg --type FUNCTION  --output /tmp/ora2pg_output/functions.sql

# Or export everything at once
ora2pg --type EXPORT_SCHEMA --output /tmp/ora2pg_output/

Step 5: Export and Load Data

bash
# Export data as PostgreSQL COPY statements (fastest method)
ora2pg --type COPY --output /tmp/ora2pg_output/data.sql

# Load into PostgreSQL
psql -U pguser -d myapp -f /tmp/ora2pg_output/tables.sql
psql -U pguser -d myapp -f /tmp/ora2pg_output/sequences.sql
psql -U pguser -d myapp -f /tmp/ora2pg_output/data.sql
psql -U pguser -d myapp -f /tmp/ora2pg_output/indexes.sql
Load data before creating indexes. Building indexes after data load is significantly faster than maintaining them during bulk inserts. Load order: tables → sequences → data → indexes → constraints → stored procedures.

PL/SQL to PL/pgSQL Conversion

PL/SQL and PL/pgSQL share a common heritage and similar syntax, but differ in key areas. ora2pg handles many conversions automatically, but complex packages and cursor usage often require manual review.

Procedure Syntax and Transaction Semantics

plpgsql
-- Oracle PL/SQL procedure
CREATE OR REPLACE PROCEDURE update_order_status(
  p_order_id IN NUMBER,
  p_status   IN VARCHAR2
) IS
BEGIN
  UPDATE orders SET status = p_status WHERE id = p_order_id;
  COMMIT;
EXCEPTION
  WHEN OTHERS THEN
    ROLLBACK;
    RAISE;
END;

-- PostgreSQL PL/pgSQL equivalent
CREATE OR REPLACE PROCEDURE update_order_status(
  p_order_id INTEGER,
  p_status   TEXT
)
LANGUAGE plpgsql AS $$
BEGIN
  UPDATE orders SET status = p_status WHERE id = p_order_id;
  -- No explicit COMMIT needed — handled by the caller's transaction context
EXCEPTION
  WHEN OTHERS THEN
    RAISE;  -- re-raise; caller handles rollback
END;
$$;

-- Calling: PostgreSQL uses CALL instead of EXEC/EXECUTE
CALL update_order_status(1234, 'shipped');

Common PL/SQL to PL/pgSQL Patterns

plpgsql
-- Oracle: IS / AS for procedure body delimiter
-- PostgreSQL: LANGUAGE plpgsql AS $$ ... $$

-- Oracle: VARCHAR2 → PostgreSQL: TEXT or VARCHAR
-- Oracle: NUMBER   → PostgreSQL: INTEGER, BIGINT, or NUMERIC

-- Oracle: DBMS_OUTPUT.PUT_LINE → PostgreSQL: RAISE NOTICE
DBMS_OUTPUT.PUT_LINE('Debug: ' || v_count);   -- Oracle
RAISE NOTICE 'Debug: %', v_count;             -- PostgreSQL

-- Oracle: NVL() → PostgreSQL: COALESCE()
v_name := NVL(p_name, 'Unknown');    -- Oracle
v_name := COALESCE(p_name, 'Unknown'); -- PostgreSQL

-- Oracle: SYSDATE → PostgreSQL: NOW() or CURRENT_TIMESTAMP
v_ts := SYSDATE;    -- Oracle
v_ts := NOW();      -- PostgreSQL

-- Oracle exception name mapping
-- NO_DATA_FOUND    → NO_DATA_FOUND (same in PG)
-- TOO_MANY_ROWS    → TOO_MANY_ROWS (same in PG)
-- OTHERS           → OTHERS (same in PG)
-- DUP_VAL_ON_INDEX → unique_violation (PG uses SQL state names)
WHEN unique_violation THEN ...   -- PostgreSQL

Oracle features that do not have direct equivalents in PostgreSQL require specific migration strategies.

Synonyms: Oracle synonyms are aliases for tables, views, or other schema objects, often used to abstract schema names in application code. PostgreSQL does not have native synonyms. The recommended approach is to use schema search path (search_path) or create views with the synonym name pointing to the underlying object.

sql
-- Oracle synonym
CREATE SYNONYM orders FOR app_schema.orders;

-- PostgreSQL equivalent using search_path
SET search_path TO app_schema, public;
-- Or create a view in the public schema
CREATE VIEW public.orders AS SELECT * FROM app_schema.orders;

Database Links (DBLinks): Oracle's database links allow cross-database queries. PostgreSQL's equivalent is the postgres_fdw (Foreign Data Wrapper) extension.

sql
-- PostgreSQL: set up postgres_fdw for cross-database queries
CREATE EXTENSION IF NOT EXISTS postgres_fdw;

CREATE SERVER remote_oracle_db
  FOREIGN DATA WRAPPER postgres_fdw
  OPTIONS (host 'remote-host', dbname 'remotedb', port '5432');

CREATE USER MAPPING FOR current_user
  SERVER remote_oracle_db
  OPTIONS (user 'remote_user', password 'secret');

-- Import remote tables
IMPORT FOREIGN SCHEMA public
  FROM SERVER remote_oracle_db
  INTO local_schema;

Oracle Packages: PL/SQL packages (header + body) have no direct equivalent in PostgreSQL. Migrate them by flattening all package procedures and functions into standalone PL/pgSQL functions in a dedicated schema, using the schema as a namespace substitute.

Testing and Validation

Migration validation is as important as the migration itself. A working schema with silent data errors is worse than a failed migration — at least a failure is obvious.

Row Count Validation

sql
-- Run on both Oracle (source) and PostgreSQL (target) and compare
SELECT 'orders'    AS table_name, COUNT(*) AS row_count FROM orders
UNION ALL
SELECT 'customers' AS table_name, COUNT(*) AS row_count FROM customers
UNION ALL
SELECT 'order_items' AS table_name, COUNT(*) AS row_count FROM order_items;

Checksum Validation for Critical Tables

sql
-- PostgreSQL: generate a checksum for critical columns
SELECT
  MD5(STRING_AGG(
    CONCAT(id::TEXT, amount::TEXT, status::TEXT, created_at::TEXT),
    ',' ORDER BY id
  )) AS table_checksum
FROM orders
WHERE created_at >= '2024-01-01';

Functional Regression Testing

After loading data into PostgreSQL, run your application's full test suite against the PostgreSQL target before any traffic is switched. Pay particular attention to:

  • Queries using ROWNUM that must be converted to LIMIT
  • Date arithmetic that assumed Oracle DATE stored time components
  • Code paths that compare columns to empty string ''
  • Any PL/SQL function that used COMMIT or ROLLBACK internally
  • Sequence-generated IDs — verify there are no gaps or conflicts after migration

Performance Baseline Comparison

sql
-- Capture execution plan on PostgreSQL for critical queries
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.status, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
  AND o.created_at >= NOW() - INTERVAL '30 days'
ORDER BY o.created_at DESC
LIMIT 100;
Run ANALYZE on all tables after the initial data load and before any performance testing. PostgreSQL's query planner relies on table statistics to choose the right execution plan, and a freshly loaded table will have no statistics until ANALYZE is run.

Key Takeaways

Key Takeaways
  • Oracle DATE stores time — always map Oracle DATE to PostgreSQL TIMESTAMPTZ, not PostgreSQL DATE
  • Empty string is not NULL in PostgreSQL — audit all WHERE column = '' comparisons in application code before cutover
  • ora2pg complexity score guides effort — score above 50 usually means package-heavy PL/SQL that requires manual conversion; plan accordingly
  • Load data before indexes — build indexes after bulk data load for orders-of-magnitude faster import times
  • No DUAL, no ROWNUM, no NVL — replace with NOW(), LIMIT, and COALESCE() respectively; create a DUAL compatibility view if needed for legacy app code
  • PL/SQL COMMIT/ROLLBACK semantics differ — PostgreSQL procedures do not auto-commit; transaction control moves to the caller
  • Validate with row counts and checksums — do not rely on a successful data load alone; verify data integrity before cutover

Working with JusDB on Oracle to PostgreSQL Migration

Oracle-to-PostgreSQL migrations range from weekend projects (small schema, minimal PL/SQL) to multi-month engagements (complex packages, tight SLA requirements, zero-downtime cutover). The technical work is tractable — the risk is in the gaps that automated tools miss: the DATE/TIMESTAMP truncation, the NULL/empty-string semantics, the package state that has no PostgreSQL equivalent.

JusDB provides hands-on Oracle-to-PostgreSQL migration services covering schema translation, ora2pg configuration and review, PL/SQL-to-PL/pgSQL conversion, cutover planning, and post-migration performance tuning. We have executed migrations ranging from 50GB single-schema databases to multi-terabyte Oracle installations with dozens of PL/SQL packages.

If you are planning a migration or are mid-project and have hit a blocker, reach out to our team. We also offer a migration assessment — a fixed-scope review of your Oracle schema and codebase that produces a detailed migration plan and effort estimate before any code is written.

Learn more about our PostgreSQL services or contact us to discuss your migration.

Related reading: MySQL to PostgreSQL Migration Guide | PostgreSQL Logical Replication Guide | AWS DMS: Database Migration Service Guide

Share this article

JusDB Team

Official JusDB content team