Oracle to PostgreSQL Migration using ORA2PG

One of the hottest topics in the PostgreSQL world is migration, and especially from Oracle to PostgreSQL. Many companies, including EDB, Cybertec, AWS, and Azure offer toolkits for cross-platform migration. This post will examine one of the oldest, but still incredibly relevant, tools, Ora2pg. Ora2pg is a great utility, full of features and completely and genuinely Open Source.

The main technical consideration, and focus, in any migration should be the code. Whether its T-Sql or plsql, conversion to plpgsql is not trivial, and in some cases impossible. Therefore, as this post is an introduction to ora2pg, it will focus on a relatively simple schema migration.

  1. Source: Oracle HR Schema (18c) - standard example schema Target: PostgreSQL v10
  1. Prerequisites:
  1. Oracle client/instant client
  2. Perl modules DBI DBD:Oracle DBD:PG (if installing with yum, DBD:Mysql is also required)
  3. Latest version of ora2pg – either through your package manager or download source
  1. Initial configuration:
ora2pg --project_base /instance1/ora2pg --init_project migv1
(this will create base directories, driver scripts, and a config file)

/instance1/ora2pg/migv1

[root@linux1 migv1]# ls
config data export_schema.sh import_all.sh reports schema sources

 

  1. Modify ora2pg.conf file - ora2pg is extremely customizable but for this demo, we will only set connectivity and schema info:
# Set Oracle database connection (datasource, user, password)
ORACLE_DSN      dbi:Oracle:host=linux1;sid=btdev18c;port=1528
ORACLE_USER     system
ORACLE_PWD      xxxxxxxxxxxx

# Oracle schema/owner to use
SCHEMA HR

# Define the following directive to send export directly to a PostgreSQL
# database. This will disable file output.
PG_DSN          dbi:Pg:dbname=hr;host=localhost;port=5432
PG_USER        btpg10
PG_PWD        xxxxxxx

 

  1. Test configuration:
# ora2pg -t SHOW_VERSION -c config/ora2pg.conf
Oracle Database 18c Enterprise Edition Release 18.0.0.0.0

 

  1. Generate migration cost report (This is very useful, it provides a difficulty assessment for the potential migration):
# ora2pg -t show_report  --estimate_cost -c config/ora2pg.conf --dump_as_html > /tmp/ora2pg.html

-------------------------------------------------------------------------------
Ora2Pg v20.0 - Database Migration Report
-------------------------------------------------------------------------------
Version  Oracle Database 18c Enterprise Edition Release 18.0.0.0.0
Schema   HR
Size     17.75 MB
Database Migration Report
Object Number Invalid Cost Comments Details
DATABASE LINK 0 0 0 Database links will be exported as SQL/MED PostgreSQL's Foreign Data Wrapper (FDW) extensions using oracle_fdw.  
GLOBAL TEMPORARY TABLE 0 0 0 Global temporary table are not supported by PostgreSQL and will not be exported. You will have to rewrite some application code to match the PostgreSQL temporary table behavior.  
INDEX 23 0 3.5 12 index(es) are concerned by the export, others are automatically generated and will do so on PostgreSQL. Bitmap will be exported as btree_gin index(es) and hash index(es) will be exported as b-tree index(es) if any. Domain index are exported as b-tree but commented to be edited to mainly use FTS. Cluster, bitmap join and IOT indexes will not be exported at all. Reverse indexes are not exported too, you may use a trigram-based index (see pg_trgm) or a reverse() function based index and search. Use 'varchar_pattern_ops', 'text_pattern_ops' or 'bpchar_pattern_ops' operators in your indexes to improve search with the LIKE operator respectively into varchar, text or char columns. 12 b-tree index(es)
JOB 0 0 0 Job are not exported. You may set external cron job with them.  
PROCEDURE 2 0 8 Total size of procedure code: 772 bytes. secure_dml: 3
add_job_history: 3
SEQUENCE 4 0 1 Sequences are fully supported, but all call to sequence_name.NEXTVAL or sequence_name.CURRVAL will be transformed into NEXTVAL('sequence_name') or CURRVAL('sequence_name').  
SYNONYM 0 0 0 SYNONYMs will be exported as views. SYNONYMs do not exists with PostgreSQL but a common workaround is to use views or set the PostgreSQL search_path in your session to access object outside the current schema.  
TABLE 14 0 1.6 2 check constraint(s). Total number of rows: 136953
Top 10 of tables sorted by number of rows:
reg_test2 has 119780 rows
reg_test3 has 16876 rows
employees has 107 rows
rec_test has 58 rows
departments has 27 rows
countries has 25 rows
locations has 23 rows
jobs has 19 rows
job_history has 10 rows
emp2 has 9 rows
Top 10 of largest tables:
TRIGGER 2 0 5 Total size of trigger code: 123 bytes. update_job_history: 3
TYPE 2 0 2 2 type(s) are concerned by the export, others are not supported. Note that Type inherited and Subtype are converted as table, type inheritance is not supported. 1 nested tables
1 object type
VIEW 1 0 1 Views are fully supported but can use specific functions.  
Total 48 0 22.10 22.10 cost migration units means approximatively 1 man-day(s). The migration unit was set to 5 minute(s)

 

 

-------------------------------------------------------------------------------
Migration level : A-3
-------------------------------------------------------------------------------

Migration levels:
    A - Migration that might be run automatically
    B - Migration with code rewrite and a human-days cost up to 5 days
    C - Migration with code rewrite and a human-days cost above 5 days
Technical levels:
    1 = trivial: no stored functions and no triggers
    2 = easy: no stored functions but with triggers, no manual rewriting
    3 = simple: stored functions and/or triggers, no manual rewriting
    4 = manual: no stored functions but with triggers or views with code rewriting
    5 = difficult: stored functions and/or triggers with code rewriting
-------------------------------------------------------------------------------

Details of cost assessment per function
Function add_job_history total estimated cost: 3
    TEST => 2
    SIZE => 1
Function secure_dml total estimated cost: 3
    TEST => 2
    SIZE => 1
-------------------------------------------------------------------------------

Details of cost assessment per trigger
Trigger update_job_history total estimated cost: 3
    TEST => 2
    SIZE => 1
-------------------------------------------------------------------------------

 

  1. Generate DDL scripts from Oracle:
./export_schema.sh
[========================>] 14/14 tables (100.0%) end of scanning.
[==>                      ]  1/11 objects types (9.1%) inspecting object DATABAS
[====>                    ]  2/11 objects types (18.2%) inspecting object GLOBAL
[======>                  ]  3/11 objects types (27.3%) inspecting object INDEX
[==========>              ]  5/11 objects types (45.5%) inspecting object PROCED
[=============>           ]  6/11 objects types (54.5%) inspecting object SEQUEN
[===============>         ]  7/11 objects types (63.6%) inspecting object SYNONY
[=================>       ]  8/11 objects types (72.7%) inspecting object TABLE
[===================>     ]  9/11 objects types (81.8%) inspecting object TRIGGE
[=====================>   ] 10/11 objects types (90.9%) inspecting object TYPE 
[========================>] 11/11 objects types (100.0%) end of objects auditing.

Running: ora2pg -p -t TABLE -o table.sql -b ./schema/tables -c ./config/ora2pg.conf
[========================>] 14/14 tables (100.0%) end of scanning.
[========================>] 14/14 tables (100.0%) end of table export.

Running: ora2pg -p -t PACKAGE -o package.sql -b ./schema/packages -c ./config/ora2pg.conf
[========================>] 0/0 packages (100.0%) end of output.

Running: ora2pg -p -t VIEW -o view.sql -b ./schema/views -c ./config/ora2pg.conf
[========================>] 1/1 views (100.0%) end of output.

Running: ora2pg -p -t GRANT -o grant.sql -b ./schema/grants -c ./config/ora2pg.conf
WARNING: Exporting privilege as non DBA user is not allowed, see USER_GRANT

Running: ora2pg -p -t SEQUENCE -o sequence.sql -b ./schema/sequences -c ./config/ora2pg.conf
[========================>] 4/4 sequences (100.0%) end of output.

Running: ora2pg -p -t TRIGGER -o trigger.sql -b ./schema/triggers -c ./config/ora2pg.conf
[========================>] 1/1 triggers (100.0%) end of output.

Running: ora2pg -p -t FUNCTION -o function.sql -b ./schema/functions -c ./config/ora2pg.conf
[========================>] 0/0 functions (100.0%) end of functions export.

Running: ora2pg -p -t PROCEDURE -o procedure.sql -b ./schema/procedures -c ./config/ora2pg.conf
[========================>] 2/2 procedures (100.0%) end of procedures export.

Running: ora2pg -p -t TABLESPACE -o tablespace.sql -b ./schema/tablespaces -c ./config/ora2pg.conf
WARNING: Exporting tablespace as non DBA user is not allowed, see USER_GRANT

Running: ora2pg -p -t PARTITION -o partition.sql -b ./schema/partitions -c ./config/ora2pg.conf
[========================>] 0/0 partitions (100.0%) end of output.

Running: ora2pg -p -t TYPE -o type.sql -b ./schema/types -c ./config/ora2pg.conf
[============>            ] 1/2 types (50.0%) generating SOURCE_CODE_COMPARE_COL
[========================>] 2/2 types (100.0%) generating SOURCE_CODE_COMPARE_CO
[========================>] 2/2 types (100.0%) end of output.

Running: ora2pg -p -t MVIEW -o mview.sql -b ./schema/mviews -c ./config/ora2pg.conf
[========================>] 0/0 materialized views (100.0%) end of output.

Running: ora2pg -p -t DBLINK -o dblink.sql -b ./schema/dblinks -c ./config/ora2pg.conf
[========================>] 0/0 dblink (100.0%) end of output.

Running: ora2pg -p -t SYNONYM -o synonym.sql -b ./schema/synonyms -c ./config/ora2pg.conf
[========================>] 0/0 synonyms (100.0%) end of output.

Running: ora2pg -p -t DIRECTORY -o directorie.sql -b ./schema/directories -c ./config/ora2pg.conf
[========================>] 0/0 directory (100.0%) end of output.

Running: ora2pg -t PACKAGE -o package.sql -b ./sources/packages -c ./config/ora2pg.conf
[========================>] 0/0 packages (100.0%) end of output.

Running: ora2pg -t VIEW -o view.sql -b ./sources/views -c ./config/ora2pg.conf
[========================>] 1/1 views (100.0%) end of output.

Running: ora2pg -t TRIGGER -o trigger.sql -b ./sources/triggers -c ./config/ora2pg.conf
[========================>] 1/1 triggers (100.0%) end of output.

Running: ora2pg -t FUNCTION -o function.sql -b ./sources/functions -c ./config/ora2pg.conf
[========================>] 0/0 functions (100.0%) end of functions export.

Running: ora2pg -t PROCEDURE -o procedure.sql -b ./sources/procedures -c ./config/ora2pg.conf
[========================>] 2/2 procedures (100.0%) end of procedures export.

Running: ora2pg -t PARTITION -o partition.sql -b ./sources/partitions -c ./config/ora2pg.conf
[========================>] 0/0 partitions (100.0%) end of output.

Running: ora2pg -t TYPE -o type.sql -b ./sources/types -c ./config/ora2pg.conf
[============>            ] 1/2 types (50.0%) generating SOURCE_CODE_COMPARE_COL
[========================>] 2/2 types (100.0%) generating SOURCE_CODE_COMPARE_CO
[========================>] 2/2 types (100.0%) end of output.

Running: ora2pg -t MVIEW -o mview.sql -b ./sources/mviews -c ./config/ora2pg.conf
[========================>] 0/0 materialized views (100.0%) end of output.

To extract data use the following command:

ora2pg -t COPY -o data.sql -b ./data -c ./config/ora2pg.conf

 

  1. Create DDL objects and load data in PostgreSQL:
# ./import_all.sh -U btpg10 -d hr -p 5432 -o btpg10
Database owner btpg10 already exists, skipping creation.

Would you like to drop the database hr before recreate it? [y/N/q] y
Running: dropdb -p 5432 -U btpg10 hr
Running: createdb -p 5432 -U btpg10 -E UTF8 --owner btpg10 hr

Would you like to import TYPE from ./schema/types/type.sql? [y/N/q] y
Running: psql --single-transaction  -p 5432 -U btpg10 -d hr -f ./schema/types/type.sql
SET
CREATE TYPE
CREATE TYPE

Would you like to import TABLE from ./schema/tables/table.sql? [y/N/q] y
Running: psql --single-transaction  -p 5432 -U btpg10 -d hr -f ./schema/tables/table.sql
SET
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
CREATE TABLE
CREATE TABLE
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
COMMENT
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE

Would you like to import VIEW from ./schema/views/view.sql? [y/N/q] y
Running: psql --single-transaction  -p 5432 -U btpg10 -d hr -f ./schema/views/view.sql
SET
SET
CREATE VIEW

Would you like to import SEQUENCE from ./schema/sequences/sequence.sql? [y/N/q] y
Running: psql --single-transaction  -p 5432 -U btpg10 -d hr -f ./schema/sequences/sequence.sql
SET
CREATE SEQUENCE
CREATE SEQUENCE
CREATE SEQUENCE
CREATE SEQUENCE

Would you like to import TRIGGER from ./schema/triggers/trigger.sql? [y/N/q] y
Running: psql --single-transaction  -p 5432 -U btpg10 -d hr -f ./schema/triggers/trigger.sql
SET
SET
psql.bin:./schema/triggers/UPDATE_JOB_HISTORY_trigger.sql:9: NOTICE:  trigger "update_job_history" for relation "employees" does not exist, skipping
DROP TRIGGER
CREATE FUNCTION
CREATE TRIGGER

Would you like to import PROCEDURE from ./schema/procedures/procedure.sql? [y/N/q] y
Running: psql --single-transaction  -p 5432 -U btpg10 -d hr -f ./schema/procedures/procedure.sql
SET
SET
psql.bin:./schema/procedures/ADD_JOB_HISTORY_procedure.sql:20: NOTICE:  type reference job_history.employee_id%TYPE converted to integer
psql.bin:./schema/procedures/ADD_JOB_HISTORY_procedure.sql:20: NOTICE:  type reference job_history.start_date%TYPE converted to timestamp without time zone
psql.bin:./schema/procedures/ADD_JOB_HISTORY_procedure.sql:20: NOTICE:  type reference job_history.end_date%TYPE converted to timestamp without time zone
psql.bin:./schema/procedures/ADD_JOB_HISTORY_procedure.sql:20: NOTICE:  type reference job_history.job_id%TYPE converted to character varying
psql.bin:./schema/procedures/ADD_JOB_HISTORY_procedure.sql:20: NOTICE:  type reference job_history.department_id%TYPE converted to smallint
CREATE FUNCTION
SET
CREATE FUNCTION

Would you like to process indexes and constraints before loading data? [y/N/q] y

Would you like to import indexes from ./schema/tables/INDEXES_table.sql? [y/N/q] n

Would you like to import constraints from ./schema/tables/CONSTRAINTS_table.sql? [y/N/q] y
Running: psql -p 5432 -U btpg10 -d hr -f ./schema/tables/CONSTRAINTS_table.sql
SET
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE

Would you like to import foreign keys from ./schema/tables/FKEYS_table.sql? [y/N/q] n

Would you like to import data from Oracle database directly into PostgreSQL? [y/N/q] y
Running: ora2pg -c config/ora2pg.conf -t COPY --pg_dsn "dbi:Pg:dbname=hr;host=localhost;port=5432" --pg_user btpg10
[========================>] 14/14 tables (100.0%) end of scanning.
[========================>] 25/25 rows (100.0%) Table COUNTRIES (25 recs/sec)
[>                        ] 25/136953 total rows (0.0%) - (0 sec., avg: 25 recs/sec).
[>                        ]     25/136953 rows (0.0%) on total estimated data (1[========================>] 27/27 rows (100.0%) Table DEPARTMENTS (27 recs/sec)
[>                        ]     52/136953 total rows (0.0%) - (0 sec., avg: 52 recs/sec).
[>                        ]     52/136953 rows (0.0%) on total estimated data (1[========================>] 9/9 rows (100.0%) Table EMP2 (9 recs/sec)          
[>                        ]     61/136953 total rows (0.0%) - (0 sec., avg: 61 recs/sec).
[>                        ]     61/136953 rows (0.0%) on total estimated data (1[========================>] 6/6 rows (100.0%) Table EMP3 (6 recs/sec)          
[>                        ]     67/136953 total rows (0.0%) - (0 sec., avg: 67 recs/sec).
[>                        ]     67/136953 rows (0.0%) on total estimated data (1[========================>] 107/107 rows (100.0%) Table EMPLOYEES (107 recs/sec)
[>                        ]    174/136953 total rows (0.1%) - (1 sec., avg: 174 recs/sec).
[>                        ]    174/136953 rows (0.1%) on total estimated data (1[========================>] 19/19 rows (100.0%) Table JOBS (19 recs/sec)       
[>                        ]    193/136953 total rows (0.1%) - (1 sec., avg: 193 recs/sec).
[>                        ]    193/136953 rows (0.1%) on total estimated data (1[========================>] 10/10 rows (100.0%) Table JOB_HISTORY (10 recs/sec) 
[>                        ]    203/136953 total rows (0.1%) - (1 sec., avg: 203 recs/sec).
[>                        ]    203/136953 rows (0.1%) on total estimated data (1[========================>] 23/23 rows (100.0%) Table LOCATIONS (23 recs/sec)  
[>                        ]    226/136953 total rows (0.2%) - (1 sec., avg: 226 recs/sec).
[>                        ]    226/136953 rows (0.2%) on total estimated data (1[========================>] 5/5 rows (100.0%) Table MINE_TEST (5 recs/sec)     
[>                        ]    231/136953 total rows (0.2%) - (1 sec., avg: 231 recs/sec).
[>                        ]    231/136953 rows (0.2%) on total estimated data (1[========================>] 58/58 rows (100.0%) Table REC_TEST (58 recs/sec)   
[>                        ]    289/136953 total rows (0.2%) - (1 sec., avg: 289 recs/sec).
[>                        ]    289/136953 rows (0.2%) on total estimated data (1[========================>] 4/4 rows (100.0%) Table REGIONS (4 recs/sec)       
[>                        ]    293/136953 total rows (0.2%) - (1 sec., avg: 293 recs/sec).
[>                        ]    293/136953 rows (0.2%) on total estimated data (1[========================>] 4/4 rows (100.0%) Table REG_TEST (4 recs/sec)      
[>                        ]    297/136953 total rows (0.2%) - (1 sec., avg: 297 recs/sec).
[>                        ]    297/136953 rows (0.2%) on total estimated data (1[==>                      ]  10000/119780 rows (8.3%)
Table REG_TEST2 (10000 recs[====>                    ]  20000/119780 rows (16.7%)
Table REG_TEST2 (20000 recs[======>                  ]  30000/119780 rows (25.0%)
Table REG_TEST2 (30000 recs[========>                ]  40000/119780 rows (33.4%)
Table REG_TEST2 (40000 recs[==========>              ]  50000/119780 rows (41.7%)
Table REG_TEST2 (50000 recs[============>            ]  60000/119780 rows (50.1%)
Table REG_TEST2 (60000 recs[==============>          ]  70000/119780 rows (58.4%)
Table REG_TEST2 (70000 recs[================>        ]  80000/119780 rows (66.8%)
Table REG_TEST2 (80000 recs[==================>      ]  90000/119780 rows (75.1%)
Table REG_TEST2 (45000 recs[====================>    ] 100000/119780 rows (83.5%)
Table REG_TEST2 (50000 recs[======================>  ] 110000/119780 rows (91.8%)
Table REG_TEST2 (55000 recs[========================>] 119780/119780 rows (100.0%)
Table REG_TEST2 (59890 recs/sec)
[=====================>   ] 120077/136953 total rows (87.7%) - (3 sec., avg: 40025 recs/sec).
[=====================>   ] 120077/136953 rows (87.7%) on total estimated data
[==============>          ] 10000/16876 rows (59.3%)
Table REG_TEST3 (10000 recs/sec)
[========================>] 16876/16876 rows (100.0%)
Table REG_TEST3 (16876 recs/sec)
[========================>] 136953/136953 total rows (100.0%) - (3 sec., avg: 45651 recs/sec).
[========================>] 136953/136953 rows (100.0%) on total estimated data (3 sec., avg: 45651 recs/sec)

 

  1. Verify objects and data in PostgreSQL:
hr=# \d
               List of relations
 Schema |       Name       |   Type   | Owner
--------+------------------+----------+--------
 public | countries        | table    | btpg10
 public | departments      | table    | btpg10
 public | departments_seq  | sequence | btpg10
 public | emp2             | table    | btpg10
 public | emp3             | table    | btpg10
 public | emp_details_view | view     | btpg10
 public | employees        | table    | btpg10
 public | employees_seq    | sequence | btpg10
 public | job_history      | table    | btpg10
 public | jobs             | table    | btpg10
 public | locations        | table    | btpg10
 public | locations_seq    | sequence | btpg10
 public | mine_test        | table    | btpg10
 public | rec_test         | table    | btpg10
 public | reg_test         | table    | btpg10
 public | reg_test2        | table    | btpg10
 public | reg_test3        | table    | btpg10
 public | regions          | table    | btpg10
 public | trans_id_s       | sequence | btpg10
(19 rows)

hr=# \df

                        List of functions
 Schema |              Name              | Result data type |                                                Argument data types                                                       |  Type
--------+--------------------------------+------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------+---------
 public | add_job_history                | void             | p_emp_id integer, p_start_date timestamp without time zone, p_end_date timestamp without time zone, p_job_id character varying, p_department_id smallint | normal
 public | secure_dml                     | void             |                                                       | normal
 public | trigger_fct_update_job_history | trigger          |                                                       | trigger
(3 rows)

hr=# select count(*) from employees;
count
------
   107
(1 row)

hr=# select * from employees limit 1;
-[ RECORD 1 ]--+--------------------
employee_id    | 100
first_name     | Steven
last_name      | King
email          | SKING
phone_number   | 515.123.4567
hire_date      | 2003-06-17 00:00:00
job_id         | AD_PRES
salary         | 24000
commission_pct |
manager_id     |
department_id  | 90