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.
- Oracle Enterprise Edition costs $25,000+ per CPU core; PostgreSQL is free and open source with comparable features for most workloads
- Key schema differences:
NUMBERvsNUMERIC, OracleDATEincludes a time component (useTIMESTAMPin PG), and empty string''isNULLin 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/ROLLBACKsemantics,IN/OUTparameters, 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.
-- 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.
-- 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)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.
-- 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 PGWHERE 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.
-- 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.
-- 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.
-- 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
# Install ora2pg on Debian/Ubuntu
sudo apt-get install ora2pg
# On RHEL/CentOS:
sudo yum install ora2pg
# Verify install
ora2pg --versionStep 2: Configure the Connection
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
EOFStep 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.
ora2pg --type SHOW_REPORT --estimate_costStep 4: Export Schema Objects
# 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
# 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.sqlPL/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
-- 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
-- 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 ... -- PostgreSQLSequences, Synonyms, and DBLinks
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.
-- 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.
-- 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
-- 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
-- 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
ROWNUMthat must be converted toLIMIT - Date arithmetic that assumed Oracle
DATEstored time components - Code paths that compare columns to empty string
'' - Any PL/SQL function that used
COMMITorROLLBACKinternally - Sequence-generated IDs — verify there are no gaps or conflicts after migration
Performance Baseline Comparison
-- 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;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
- Oracle DATE stores time — always map Oracle
DATEto PostgreSQLTIMESTAMPTZ, not PostgreSQLDATE - 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, andCOALESCE()respectively; create aDUALcompatibility 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
