Oracle is still the last big on-premises warehouse in a lot of estates. Teams migrate Teradata and SQL Server first because those are "obviously" analytical, then discover that a 20-year-old Oracle Exadata or 19c data mart is carrying the finance close, a nightly PL/SQL batch nobody has read since 2014, and a dozen materialized views that downstream reports depend on.
This playbook covers the Oracle-specific parts of a Snowflake migration: what SnowConvert does and does not handle, how PL/SQL maps onto Snowflake Scripting and Snowpark, the CDC options for a parallel run, and the data-type and NULL semantics that quietly corrupt reconciliation if you ignore them.
Phase 0: inventory before you touch anything
Pull the facts out of the Oracle data dictionary rather than asking people what is used.
-- Objects by type and schema
SELECT owner, object_type, COUNT(*)
FROM dba_objects
WHERE owner NOT IN ('SYS','SYSTEM','DBSNMP','OUTLN')
GROUP BY owner, object_type
ORDER BY 1,2;
-- Biggest segments: where the data (and the cost) actually is
SELECT owner, segment_name, ROUND(SUM(bytes)/1024/1024/1024,1) gb
FROM dba_segments GROUP BY owner, segment_name ORDER BY gb DESC FETCH FIRST 50 ROWS ONLY;
-- What is genuinely queried (requires Diagnostics Pack licensing)
SELECT o.owner, o.object_name, COUNT(*) execs
FROM dba_hist_sql_plan p
JOIN dba_objects o ON o.object_id = p.object#
GROUP BY o.owner, o.object_name ORDER BY execs DESC;
Classify every object into migrate, rebuild, or retire. In a typical Oracle mart 30-50% of objects are staging leftovers and one-off copies that should never reach Snowflake. Retiring them is the cheapest work in the whole programme.
Also inventory the things that have no Snowflake equivalent and need a design decision, not a translation:
DBMS_SCHEDULERjobs → Snowflake tasks, or your existing orchestrator (Airflow, dbt, Openflow)- Database links → ingestion pipelines or Snowflake data sharing
- Oracle Spatial / Text / Advanced Queuing → rethink, not convert
- Sequences used for surrogate keys → Snowflake sequences or hash keys
- Fine-grained access control (VPD) and Oracle Label Security → Snowflake row access policies and masking policies
Phase 1: schema conversion with SnowConvert
SnowConvert (free from Snowflake, with an AI-assisted edition) reads DDL and PL/SQL extracts and emits Snowflake SQL plus a conversion report. Give it a clean extract:
# Extract DDL with Oracle's metadata API, one file per schema
expdp sales/pw schemas=SALES content=metadata_only directory=dp dumpfile=sales.dmp
impdp sales/pw sqlfile=sales_ddl.sql directory=dp dumpfile=sales.dmp
snowct oracle --input ./ddl --output ./converted --generate-report
What converts well: tables, views, constraints, sequences, most SQL (CONNECT BY, analytic functions, MERGE), and a large share of straightforward PL/SQL procedures.
What always needs hands:
| Oracle | Snowflake treatment |
|---|---|
NUMBER with no precision | SnowConvert emits NUMBER(38,18); profile the real values and tighten — wide decimals bloat storage and slow joins |
VARCHAR2(n BYTE) | VARCHAR(n) is character-based; multibyte data that fit in Oracle bytes still fit, but length checks in app code change |
DATE (has a time component) | TIMESTAMP_NTZ, not DATE. This one silently breaks joins and filters |
CLOB / BLOB | VARCHAR/BINARY up to 16 MB; anything larger goes to a stage and is referenced by URL |
Empty string '' = NULL | Snowflake treats '' as a real empty string. Any IS NULL logic on text columns must be re-tested |
ROWID, ROWNUM | ROW_NUMBER()/QUALIFY; ROWID has no equivalent — find the natural key |
| Materialized views with fast refresh | Dynamic tables (declarative, incremental) in almost every case |
| Partitioning / local indexes | Nothing to migrate: micro-partitions plus a clustering key on the high-cardinality filter column, if needed at all |
| Global temporary tables | Snowflake temporary or transient tables, scoped to the session |
A worked example of the DATE trap:
-- Oracle: TRUNC strips time from a DATE
SELECT * FROM orders WHERE TRUNC(order_date) = DATE '2026-03-01';
-- Snowflake equivalent that stays sargable (pruning-friendly)
SELECT * FROM orders
WHERE order_date >= '2026-03-01'::TIMESTAMP_NTZ
AND order_date < '2026-03-02'::TIMESTAMP_NTZ;
Phase 2: PL/SQL — translate, or redesign?
Three honest options, and the right answer is usually a mix:
- Snowflake Scripting. Closest 1:1 target for procedural logic; SnowConvert maps
BEGIN/EXCEPTION/END, cursors, and local variables. Good for batch control logic you must preserve verbatim for audit reasons. - Snowpark (Python). Better for anything with real algorithmic content, loops over business rules, or external calls. Testable in CI with pytest.
- Declarative rewrite. Most Oracle batch PL/SQL is a row-by-row implementation of what is actually a
MERGEplus a fewUPDATEs. Rewriting it as set-based SQL or dbt models is more work up front and roughly an order of magnitude faster at runtime.
A cursor loop converted literally:
CREATE OR REPLACE PROCEDURE sales.refresh_customer_tier()
RETURNS STRING
LANGUAGE SQL
AS
$$
DECLARE
c CURSOR FOR SELECT customer_id, ltv FROM sales.customer_metrics;
n INT DEFAULT 0;
BEGIN
FOR r IN c DO
UPDATE sales.customers SET tier = CASE WHEN r.ltv > 100000 THEN 'GOLD' ELSE 'STANDARD' END
WHERE customer_id = r.customer_id;
n := n + 1;
END FOR;
RETURN 'rows: ' || n;
END;
$$;
...and the version you should actually ship:
MERGE INTO sales.customers t
USING sales.customer_metrics s ON t.customer_id = s.customer_id
WHEN MATCHED THEN UPDATE SET tier = IFF(s.ltv > 100000, 'GOLD', 'STANDARD');
Rule of thumb: convert literally to get the parallel run green, then refactor the top 20 procedures by runtime once reconciliation passes. Do not try to do both at the same time — you lose the ability to say which change broke a number.
Phase 3: moving the data
History (one-time bulk). Unload to Parquet and COPY INTO. Parquet keeps types; CSV loses them and makes NULL-versus-empty-string worse.
-- Oracle 21c+/23ai: direct Parquet unload to object storage
BEGIN
DBMS_CLOUD.EXPORT_DATA(
credential_name => 'OCI_CRED',
file_uri_list => 'https://.../bucket/orders/',
format => '{"type":"parquet","maxfilesize":"256000000"}',
query => 'SELECT * FROM sales.orders');
END;
/
On older versions use sqluldr2, Oracle Data Pump plus a conversion step, or a vendor extract tool. Target 100-250 MB compressed files; one 400 GB file loads on a single thread and ruins your weekend.
COPY INTO raw.orders
FROM @lake_stage/orders/
FILE_FORMAT = (TYPE = PARQUET)
MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE
ON_ERROR = ABORT_STATEMENT;
Ongoing change (CDC for the parallel run). Options, roughly in order of how much Snowflake-native they are:
- Openflow (Snowflake's managed NiFi-based service) with a database connector — increasingly the default for Oracle CDC into Snowflake when you want the pipeline inside the platform
- LogMiner / GoldenGate writing to object storage, consumed with Snowpipe
- Debezium → Kafka → Snowpipe Streaming if a Kafka backbone already exists
- Vendor CDC (Fivetran, Qlik, HVR, Striim) where supportability matters more than cost
Whatever you choose, require Oracle supplemental logging on the source tables and agree who owns archive-log growth. That conversation, not the Snowflake side, is what delays most Oracle CDC projects.
Phase 4: reconcile, in public
Run Oracle and Snowflake in parallel for at least two full business cycles (including a month-end close) and publish a daily scorecard.
-- Snowflake side of a row/aggregate/hash comparison
SELECT 'orders' AS table_name,
COUNT(*) AS row_count,
SUM(amount) AS sum_amount,
SUM(HASH(order_id, customer_id, order_date, amount, status)) AS content_hash
FROM sales.orders
WHERE order_date < CURRENT_DATE;
Checklist for reconciliation that actually finds bugs:
- Row counts and keyed anti-joins both ways, not just counts
- Sum and min/max per numeric column, with an explicit tolerance for
NUMBERrounding - Content hashes on a canonicalised projection (trim text, cast timestamps to a fixed precision, normalise
''to NULL) - Known-divergence register: every accepted difference written down with a reason and an owner
- Report-level comparison, not just table-level — BI tools reintroduce their own joins and filters
Automate it as Data Metric Functions or a dbt test suite so it runs unattended every morning.
Phase 5: cutover and decommission
- Freeze Oracle DDL early; schema drift during a migration is the top cause of slipped dates
- Repoint BI and extracts behind views so cutover is a view swap, not a hundred tool edits
- Keep Oracle read-only for one close cycle after cutover, then archive and stop paying for it — the business case only lands when the licence actually goes away
- Rebuild security as Snowflake RBAC up front (functional roles, access roles), not as a copy of Oracle grants
- Set resource monitors and budgets before go-live; the first month after cutover is when unoptimised converted SQL meets elastic compute
Realistic sizing
For a mid-size Oracle mart — a few hundred tables, 10-30 TB, a hundred or so PL/SQL programs — plan on roughly: 2-3 weeks of inventory and design, 4-6 weeks of conversion and data movement, 6-8 weeks of parallel running and reconciliation, and a cutover weekend. The conversion tooling is rarely the long pole; agreeing what "the numbers match" means is.
PowderInsights runs Oracle-to-Snowflake migrations end to end — assessment, SnowConvert output remediation, PL/SQL redesign, CDC, and reconciliation. Get in touch with your object counts and we will tell you honestly which phase will hurt.