Oracle Schema Migration -- PL/SQL Conversion Patterns¶
Comprehensive data type mapping and PL/SQL to T-SQL / PL/pgSQL conversion patterns for Oracle-to-Azure migrations.
Scope
This guide covers the schema conversion layer -- data types, SQL syntax, PL/SQL procedural constructs, and Oracle-specific idioms. For target-specific guidance, see Azure SQL MI Migration or PostgreSQL Migration. For the full feature mapping, see Feature Mapping.
1. Data type mapping reference¶
1.1 Numeric types¶
| Oracle | Azure SQL MI (T-SQL) | PostgreSQL | Notes |
|---|---|---|---|
NUMBER(1) | bit | boolean | For true/false flags |
NUMBER(3) | tinyint (0-255) or smallint | smallint | Range matters |
NUMBER(5) | smallint | smallint | |
NUMBER(10) | int | integer | Most common integer |
NUMBER(19) | bigint | bigint | Large identifiers |
NUMBER(p,s) where s > 0 | decimal(p,s) | numeric(p,s) | Exact decimal |
NUMBER (no precision) | float(53) | double precision | Caution: loses arbitrary precision |
BINARY_FLOAT | real | real | 32-bit IEEE 754 |
BINARY_DOUBLE | float(53) | double precision | 64-bit IEEE 754 |
PLS_INTEGER / BINARY_INTEGER | int | integer | PL/SQL only; use in variables |
1.2 String types¶
| Oracle | Azure SQL MI (T-SQL) | PostgreSQL | Notes |
|---|---|---|---|
VARCHAR2(n BYTE) | varchar(n) or nvarchar(n) | varchar(n) | Use nvarchar in SQL Server for Unicode |
VARCHAR2(n CHAR) | nvarchar(n) | varchar(n) | Character semantics |
NVARCHAR2(n) | nvarchar(n) | varchar(n) | PostgreSQL varchar is Unicode by default |
CHAR(n) | nchar(n) | char(n) | Fixed-width |
NCHAR(n) | nchar(n) | char(n) | |
CLOB | nvarchar(max) | text | Up to 2 GB |
NCLOB | nvarchar(max) | text | |
LONG | nvarchar(max) | text | Deprecated in Oracle |
1.3 Date and time types¶
| Oracle | Azure SQL MI (T-SQL) | PostgreSQL | Notes |
|---|---|---|---|
DATE | datetime2(0) | timestamp(0) | Oracle DATE includes time; SQL Server DATE does not |
TIMESTAMP(p) | datetime2(p) | timestamp(p) | p = fractional seconds (max 7 for SQL Server, 6 for PG) |
TIMESTAMP WITH TIME ZONE | datetimeoffset(7) | timestamptz | With timezone offset |
TIMESTAMP WITH LOCAL TIME ZONE | datetime2 + app logic | timestamptz | Session-dependent display |
INTERVAL YEAR TO MONTH | Calculate with DATEDIFF | interval | PostgreSQL has native interval |
INTERVAL DAY TO SECOND | Calculate with DATEDIFF | interval | PostgreSQL has native interval |
Oracle DATE trap
The most common mapping error is Oracle DATE to SQL Server date. Oracle's DATE type stores both date and time (to the second). SQL Server's date type stores only the date. Always map Oracle DATE to datetime2(0) in SQL Server or timestamp(0) in PostgreSQL.
1.4 Binary and LOB types¶
| Oracle | Azure SQL MI (T-SQL) | PostgreSQL | Notes |
|---|---|---|---|
BLOB | varbinary(max) | bytea | Binary large object |
RAW(n) | varbinary(n) | bytea | Fixed-size binary |
LONG RAW | varbinary(max) | bytea | Deprecated in Oracle |
BFILE | External reference + varbinary(max) | External reference + bytea | Store in Azure Blob, keep reference |
1.5 Special types¶
| Oracle | Azure SQL MI (T-SQL) | PostgreSQL | Notes |
|---|---|---|---|
XMLTYPE | xml | xml | XQuery syntax differs |
SDO_GEOMETRY | geometry / geography | PostGIS geometry | Requires PostGIS extension |
SYS.ANYDATA | sql_variant | jsonb | Polymorphic type |
ROWID | No equivalent | ctid (unstable) | Use logical keys |
UROWID | No equivalent | No equivalent | Use logical keys |
BOOLEAN (PL/SQL only) | bit | boolean | |
JSON (21c) | nvarchar(max) + JSON functions | jsonb | PostgreSQL jsonb is strongly typed |
2. Oracle SQL idiom conversion¶
2.1 DUAL table¶
-- Oracle
SELECT SYSDATE FROM DUAL;
SELECT 1 + 1 FROM DUAL;
SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL;
2.2 ROWNUM and pagination¶
-- Oracle: Top N rows
SELECT * FROM employees WHERE ROWNUM <= 10;
-- Oracle: Pagination (pre-12c)
SELECT * FROM (
SELECT e.*, ROWNUM rn FROM (
SELECT * FROM employees ORDER BY salary DESC
) e WHERE ROWNUM <= 20
) WHERE rn > 10;
-- Oracle 12c+: FETCH FIRST
SELECT * FROM employees ORDER BY salary DESC
FETCH FIRST 10 ROWS ONLY;
-- Oracle 12c+: OFFSET/FETCH
SELECT * FROM employees ORDER BY salary DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;
-- T-SQL (Azure SQL MI)
SELECT TOP 10 * FROM employees;
-- Pagination
SELECT * FROM employees ORDER BY salary DESC
OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;
-- PostgreSQL
SELECT * FROM employees LIMIT 10;
-- Pagination
SELECT * FROM employees ORDER BY salary DESC
LIMIT 10 OFFSET 10;
2.3 NVL, NVL2, DECODE, COALESCE¶
-- Oracle
SELECT NVL(commission_pct, 0) FROM employees;
SELECT NVL2(commission_pct, salary * commission_pct, 0) FROM employees;
SELECT DECODE(status, 'A', 'Active', 'I', 'Inactive', 'Unknown') FROM employees;
-- T-SQL
SELECT ISNULL(commission_pct, 0) FROM employees;
-- or COALESCE(commission_pct, 0) for ANSI compliance
SELECT CASE WHEN commission_pct IS NOT NULL
THEN salary * commission_pct ELSE 0 END FROM employees;
-- or IIF(commission_pct IS NOT NULL, salary * commission_pct, 0)
SELECT CASE status WHEN 'A' THEN 'Active'
WHEN 'I' THEN 'Inactive'
ELSE 'Unknown' END FROM employees;
-- PostgreSQL
SELECT COALESCE(commission_pct, 0) FROM employees;
SELECT CASE WHEN commission_pct IS NOT NULL
THEN salary * commission_pct ELSE 0 END FROM employees;
SELECT CASE status WHEN 'A' THEN 'Active'
WHEN 'I' THEN 'Inactive'
ELSE 'Unknown' END FROM employees;
2.4 String concatenation¶
-- Oracle
SELECT first_name || ' ' || last_name AS full_name FROM employees;
SELECT 'Employee: ' || TO_CHAR(employee_id) FROM employees;
-- T-SQL
SELECT first_name + ' ' + last_name AS full_name FROM employees;
-- or CONCAT(first_name, ' ', last_name)
SELECT 'Employee: ' + CAST(employee_id AS nvarchar(10)) FROM employees;
-- or CONCAT('Employee: ', employee_id)
-- PostgreSQL
SELECT first_name || ' ' || last_name AS full_name FROM employees;
-- Same as Oracle! (|| is standard SQL)
SELECT 'Employee: ' || employee_id::text FROM employees;
2.5 Date arithmetic¶
-- Oracle
SELECT SYSDATE + 30 FROM DUAL; -- Add 30 days
SELECT ADD_MONTHS(SYSDATE, 3) FROM DUAL; -- Add 3 months
SELECT MONTHS_BETWEEN(date1, date2) FROM DUAL; -- Months between
SELECT TRUNC(SYSDATE) FROM DUAL; -- Truncate to day
SELECT TRUNC(SYSDATE, 'MM') FROM DUAL; -- Truncate to month
SELECT EXTRACT(YEAR FROM SYSDATE) FROM DUAL; -- Extract year
SELECT LAST_DAY(SYSDATE) FROM DUAL; -- Last day of month
SELECT NEXT_DAY(SYSDATE, 'MONDAY') FROM DUAL; -- Next Monday
-- T-SQL
SELECT DATEADD(day, 30, GETDATE());
SELECT DATEADD(month, 3, GETDATE());
SELECT DATEDIFF(month, date2, date1);
SELECT CAST(GETDATE() AS date);
SELECT DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1);
SELECT YEAR(GETDATE());
SELECT EOMONTH(GETDATE());
-- NEXT_DAY: no direct equivalent; use DATEADD with calculation
-- PostgreSQL
SELECT now() + interval '30 days';
SELECT now() + interval '3 months';
SELECT EXTRACT(EPOCH FROM age(date1, date2)) / 2629746; -- approximate months
SELECT date_trunc('day', now());
SELECT date_trunc('month', now());
SELECT EXTRACT(YEAR FROM now());
SELECT (date_trunc('month', now()) + interval '1 month - 1 day')::date;
-- Or for last_day: SELECT (date_trunc('month', now() + interval '1 month') - interval '1 day')::date;
2.6 CONNECT BY hierarchical queries¶
-- Oracle: Employee org chart with path
SELECT
LPAD(' ', 2 * (LEVEL - 1)) || employee_name AS org_tree,
employee_id,
manager_id,
LEVEL,
SYS_CONNECT_BY_PATH(employee_name, ' > ') AS path,
CONNECT_BY_ISLEAF AS is_leaf
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR employee_id = manager_id
ORDER SIBLINGS BY employee_name;
-- T-SQL: Recursive CTE
WITH org_cte AS (
-- Anchor: top-level managers
SELECT
CAST(employee_name AS nvarchar(max)) AS org_tree,
employee_id,
manager_id,
1 AS lvl,
CAST(' > ' + employee_name AS nvarchar(max)) AS path,
CASE WHEN NOT EXISTS (
SELECT 1 FROM employees c WHERE c.manager_id = employees.employee_id
) THEN 1 ELSE 0 END AS is_leaf
FROM employees
WHERE manager_id IS NULL
UNION ALL
-- Recursive: subordinates
SELECT
CAST(REPLICATE(' ', 2 * o.lvl) + e.employee_name AS nvarchar(max)),
e.employee_id,
e.manager_id,
o.lvl + 1,
CAST(o.path + ' > ' + e.employee_name AS nvarchar(max)),
CASE WHEN NOT EXISTS (
SELECT 1 FROM employees c WHERE c.manager_id = e.employee_id
) THEN 1 ELSE 0 END
FROM employees e
INNER JOIN org_cte o ON e.manager_id = o.employee_id
)
SELECT * FROM org_cte ORDER BY path;
-- PostgreSQL: Recursive CTE (similar to T-SQL)
WITH RECURSIVE org_cte AS (
SELECT
employee_name AS org_tree,
employee_id,
manager_id,
1 AS lvl,
' > ' || employee_name AS path
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT
repeat(' ', 2 * o.lvl) || e.employee_name,
e.employee_id,
e.manager_id,
o.lvl + 1,
o.path || ' > ' || e.employee_name
FROM employees e
INNER JOIN org_cte o ON e.manager_id = o.employee_id
)
SELECT * FROM org_cte ORDER BY path;
2.7 Analytic functions¶
Most Oracle analytic functions work identically in T-SQL and PostgreSQL:
-- Oracle (works the same in T-SQL and PostgreSQL)
SELECT
employee_id,
department_id,
salary,
ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rank_in_dept,
DENSE_RANK() OVER (ORDER BY salary DESC) AS overall_rank,
LAG(salary) OVER (ORDER BY hire_date) AS prev_salary,
LEAD(salary) OVER (ORDER BY hire_date) AS next_salary,
SUM(salary) OVER (PARTITION BY department_id) AS dept_total,
AVG(salary) OVER () AS company_avg,
NTILE(4) OVER (ORDER BY salary) AS quartile
FROM employees;
Oracle-specific analytic differences:
| Oracle | T-SQL | PostgreSQL |
|---|---|---|
LISTAGG(col, ',') WITHIN GROUP (ORDER BY col) | STRING_AGG(col, ',') WITHIN GROUP (ORDER BY col) | string_agg(col, ',' ORDER BY col) |
RATIO_TO_REPORT(salary) OVER () | salary * 1.0 / SUM(salary) OVER () | salary::numeric / SUM(salary) OVER () |
MEDIAN(salary) | PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) | percentile_cont(0.5) WITHIN GROUP (ORDER BY salary) |
KEEP (DENSE_RANK FIRST ORDER BY ...) | Subquery or FIRST_VALUE() | Subquery or FIRST_VALUE() |
3. Autonomous transaction patterns¶
Oracle autonomous transactions are used for logging, auditing, and sequence management that must commit independently. Neither T-SQL nor PL/pgSQL has a direct equivalent.
3.1 Logging pattern¶
-- Oracle: Autonomous transaction for audit logging
CREATE OR REPLACE PROCEDURE log_audit(
p_action VARCHAR2,
p_details VARCHAR2
) IS
PRAGMA AUTONOMOUS_TRANSACTION;
BEGIN
INSERT INTO audit_log (action, details, log_time, session_user)
VALUES (p_action, p_details, SYSTIMESTAMP, SYS_CONTEXT('USERENV','SESSION_USER'));
COMMIT;
END;
/
-- T-SQL: Use table variable (survives ROLLBACK) or Service Bus
CREATE OR ALTER PROCEDURE dbo.log_audit
@action nvarchar(100),
@details nvarchar(max)
AS
BEGIN
-- Option 1: Direct insert (will be rolled back if caller rolls back)
INSERT INTO audit_log (action, details, log_time, session_user)
VALUES (@action, @details, SYSDATETIMEOFFSET(), SUSER_SNAME());
-- Option 2: Use Azure Service Bus for fire-and-forget logging
-- (external queue ensures log survives transaction rollback)
END;
-- PostgreSQL: Use dblink for autonomous-like behavior
CREATE EXTENSION IF NOT EXISTS dblink;
CREATE OR REPLACE PROCEDURE log_audit(
p_action text,
p_details text
) AS $$
BEGIN
PERFORM dblink_exec(
'dbname=' || current_database() || ' user=' || current_user,
format('INSERT INTO audit_log (action, details, log_time, session_user)
VALUES (%L, %L, now(), current_user)', p_action, p_details)
);
END;
$$ LANGUAGE plpgsql;
4. Oracle-specific SQL constructs¶
4.1 MERGE statement¶
-- Oracle MERGE
MERGE INTO target_table t
USING source_table s ON (t.id = s.id)
WHEN MATCHED THEN
UPDATE SET t.name = s.name, t.updated_at = SYSDATE
WHEN NOT MATCHED THEN
INSERT (id, name, created_at) VALUES (s.id, s.name, SYSDATE);
-- T-SQL MERGE (nearly identical syntax)
MERGE INTO target_table AS t
USING source_table AS s ON t.id = s.id
WHEN MATCHED THEN
UPDATE SET t.name = s.name, t.updated_at = GETDATE()
WHEN NOT MATCHED THEN
INSERT (id, name, created_at) VALUES (s.id, s.name, GETDATE());
-- PostgreSQL: INSERT ... ON CONFLICT (upsert)
INSERT INTO target_table (id, name, created_at)
SELECT id, name, now() FROM source_table
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name, updated_at = now();
4.2 Flashback queries¶
-- Oracle: Query data as of a past time
SELECT * FROM employees AS OF TIMESTAMP
TO_TIMESTAMP('2024-01-15 10:00:00', 'YYYY-MM-DD HH24:MI:SS');
-- Oracle: Flashback query with SCN
SELECT * FROM employees AS OF SCN 1234567;
-- T-SQL: Temporal tables (must be pre-configured)
-- 1. Create temporal table
ALTER TABLE employees ADD
valid_from datetime2 GENERATED ALWAYS AS ROW START NOT NULL DEFAULT SYSUTCDATETIME(),
valid_to datetime2 GENERATED ALWAYS AS ROW END NOT NULL DEFAULT CAST('9999-12-31 23:59:59.9999999' AS datetime2),
PERIOD FOR SYSTEM_TIME (valid_from, valid_to);
ALTER TABLE employees SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.employees_history));
-- 2. Query historical data
SELECT * FROM employees FOR SYSTEM_TIME AS OF '2024-01-15T10:00:00';
-- PostgreSQL: No built-in temporal tables
-- Use triggers + history table pattern or temporal_tables extension
4.3 Global temporary tables¶
-- Oracle: Global temporary table
CREATE GLOBAL TEMPORARY TABLE temp_results (
id NUMBER,
result VARCHAR2(100)
) ON COMMIT DELETE ROWS; -- or ON COMMIT PRESERVE ROWS
-- T-SQL: Temporary tables
-- Session-scoped (prefix with #)
CREATE TABLE #temp_results (
id int,
result nvarchar(100)
);
-- Automatically dropped when session ends
-- Or table variable (for small datasets)
DECLARE @temp_results TABLE (
id int,
result nvarchar(100)
);
-- PostgreSQL: Temporary tables
CREATE TEMPORARY TABLE temp_results (
id integer,
result varchar(100)
) ON COMMIT DROP; -- or ON COMMIT DELETE ROWS / ON COMMIT PRESERVE ROWS
5. Schema conversion workflow¶
5.1 Recommended conversion order¶
1. Sequences (XS effort, no dependencies)
2. Tables + constraints (S effort, defines structure)
3. Indexes (S effort, after tables)
4. Views (S-M effort, depends on tables)
5. Functions (M effort, may depend on types)
6. Procedures (M effort, may depend on functions)
7. Packages (M-L effort, decompose to schema + functions/procs)
8. Triggers (M effort, after tables and procs)
9. Types (M-L effort, may need redesign)
10. Grants / security (M effort, after all objects)
5.2 Validation checklist¶
- All tables created with correct data types
- Primary keys, unique constraints, foreign keys migrated
- Check constraints migrated and validated
- Default values converted (SYSDATE to GETDATE()/now(), etc.)
- Sequences created with correct START WITH and INCREMENT BY
- Indexes created (review for Azure SQL MI / PostgreSQL optimizer differences)
- Views converted (syntax changes applied)
- Functions and procedures converted and unit-tested
- Packages decomposed to schemas with individual objects
- Triggers converted (BEFORE to AFTER/INSTEAD OF for SQL Server)
- Grants and permissions mapped to target RBAC model
- Row counts match between source and target
- Key business queries produce identical results
Maintainers: csa-inabox core team Last updated: 2026-04-30