Convert Oracle DDL to MySQL with the right data types and a pre-flight query for every risky column
10 min read Migration The Adapters team
Field mapping auto-plugged · tap a port to rewire
Plug a source port into
Transform on this cable
JSON in
JSON out
5 sample records ready
To convert Oracle DDL to MySQL, generate a draft with a converter, then override four decisions no converter can make for you: Oracle DATE becomes DATETIME, every NUMBER gets a measured DECIMAL or integer type, zoned timestamps are stored in UTC with the zone beside them, and you choose what an empty string means. Everything else in this page's mapping table is mechanical. The part converters never ship is the query to run on Oracle that proves each choice safe before anything loads.
Key takeaways
- Oracle DATE has a time in it. Oracle stores hour, minute and second in every DATE. Map it to DATETIME, never to MySQL DATE.
- NUMBER is not one type. It can be an integer, an exact decimal or effectively unbounded. Each needs its own MySQL target, and none of them is DOUBLE.
- AWS's own examples produce DOUBLE. The SCT guide's converted procedures declare Oracle NUMBER parameters as DOUBLE, which MySQL documents as approximate.
- The empty string changes meaning. Oracle treats '' as NULL. MySQL keeps it as a value, so NOT NULL stops keeping blanks out.
- Check on Oracle, reconcile on values. Each risk here is one catalog query on the source and a week of cleanup once it reaches production.
How do I convert Oracle DDL to MySQL?
Extract the DDL with DBMS_METADATA.GET_DDL or let a converter read the catalog, strip
the Oracle storage clauses (TABLESPACE, PCTFREE, STORAGE, LOGGING), swap double quotes for
backticks on any quoted identifier, and then map the types. AWS SCT and SQLines both produce a
workable first draft, and you will keep most of it. The real work is finding the defaults to
reject, because each one produces valid MySQL DDL and a successful load.
One tool you may expect to use is missing from that list. The MySQL Workbench manual names seven supported migration sources: SQL Server, Access, MySQL, PostgreSQL, SQL Anywhere, SQLite and Sybase ASE. Oracle is not one of them. It can only go through the generic ODBC path, which the manual describes as "less automatic", so if your plan assumed Workbench would map Oracle types for you, the mapping table below is the job you actually have.
Oracle to MySQL data type mapping table
Every row was checked against the Oracle 19c SQL Language Reference and the MySQL 8.4 Reference Manual on 1 October 2026. Integer ranges are for signed MySQL types.
| Oracle type | MySQL type | Note |
|---|---|---|
| NUMBER(p,s), s > 0 | DECIMAL(p,s) | Exact in both engines. Carry precision and scale across unchanged. |
| NUMBER (no precision) | DECIMAL sized from the data | Oracle gives it maximum range and precision. Profile it, then choose. Not DOUBLE. |
| NUMBER(1) to NUMBER(2) | TINYINT | Signed TINYINT holds -128 to 127, enough for two digits. |
| NUMBER(3) to NUMBER(4) | SMALLINT | Signed SMALLINT stops at 32,767. |
| NUMBER(5) to NUMBER(9) | INT | Nine digits fit comfortably under the signed INT ceiling of 2,147,483,647. |
| NUMBER(10) to NUMBER(18) | BIGINT | NUMBER(10) can reach 9,999,999,999, past the INT ceiling. |
| NUMBER(19) to NUMBER(38) | DECIMAL(p,0) | BIGINT tops out at 9,223,372,036,854,775,807, below the largest 19-digit value. |
| NUMBER(p,-s) | DECIMAL(p,0) | Negative scale rounds to tens or thousands in Oracle. MySQL DECIMAL has no negative scale. |
| FLOAT(p) | DOUBLE | Oracle FLOAT is a NUMBER subtype with binary precision. DOUBLE is the closest fit. |
| BINARY_FLOAT, BINARY_DOUBLE | FLOAT, DOUBLE | 32-bit and 64-bit floating point on both sides. Clean. |
| DATE | DATETIME | Oracle DATE stores hour, minute and second. MySQL DATE does not. |
| TIMESTAMP(p) | DATETIME(p), p up to 6 | Oracle allows up to 9 fractional digits. MySQL stops at 6. |
| TIMESTAMP WITH TIME ZONE | DATETIME(6) in UTC, plus a zone column | No MySQL type keeps the zone with the value. |
| TIMESTAMP WITH LOCAL TIME ZONE | DATETIME(6) in UTC | Oracle normalizes it to the database time zone, so convert once and store UTC. |
| INTERVAL types | BIGINT seconds, or two columns | MySQL has no interval column type. |
| VARCHAR2(n BYTE) | VARCHAR(n) | Bytes in Oracle, characters in MySQL. Check the row width in utf8mb4. |
| VARCHAR2(n CHAR), NVARCHAR2(n) | VARCHAR(n) in utf8mb4 | The column character set does the work of the national types. |
| CHAR(n), NCHAR(n) | CHAR(n) | MySQL removes trailing spaces from CHAR values on retrieval. |
| CLOB, NCLOB, LONG | LONGTEXT | TEXT caps at 64 KB. LONGTEXT is the only safe target. |
| BLOB, LONG RAW | LONGBLOB | One target for both. |
| RAW(n) | VARBINARY(n) | RAW(16) holding GUIDs maps well to BINARY(16). |
| XMLTYPE | LONGTEXT | MySQL has no XML type. |
| ROWID, UROWID | Drop it | A physical address inside Oracle. It means nothing after the move. |
| SEQUENCE plus trigger | AUTO_INCREMENT | MySQL has no CREATE SEQUENCE. Shared sequences need a counter table. |
What is the MySQL equivalent of Oracle DATE?
DATETIME. Oracle's reference is explicit: "For each DATE value, Oracle stores the following information: year, month, day, hour, minute, and second." MySQL DATE stores the calendar date and nothing else. Converters get this right, and SQLines, for one, publishes DATE to DATETIME. The damage comes from hand-edited DDL, find-and-replace scripts and ORMs that regenerate the schema from names, all of which keep the word DATE and drop the time of day on every row.
Nothing fails when that happens. Row counts match, every date is correct, and the only clue is that every order now appears to have been placed at midnight. The check is one line, listed in the table further down. Run it even if you are sure, because it also tells the business whether reports that group by hour are about to change.
What is the MySQL equivalent of Oracle NUMBER?
It depends on how the column was declared, which is why NUMBER is the hardest type on this route. NUMBER(p,s) with a positive scale is an exact decimal and becomes DECIMAL(p,s). NUMBER(p) is an integer, and the right MySQL integer depends on p: NUMBER(10) can hold 9,999,999,999, which is past the INT ceiling of 2,147,483,647, and NUMBER(19) can exceed BIGINT. A bare NUMBER with no precision is, in Oracle's words, "the maximum range and precision for an Oracle number", and many older schemas declare every money and quantity column that way because Oracle never forced a choice.
The worst choice for any of them is DOUBLE. MySQL's manual says floating-point numbers "are
approximate and not stored as exact values", and that comparing them for equality "may lead to
problems." Yet the published Oracle to MySQL examples in AWS's SCT guide convert a procedure
parameter declared p_state IN NUMBER to par_P_STATE DOUBLE, and a NUMBER
variable to DOUBLE as well. SQLines lists bare NUMBER as "DECIMAL(p,s) or DOUBLE". If your
procedures add up invoices, change every DOUBLE in the converted signatures to the DECIMAL the table
column uses. Totals that are wrong by a fraction of a cent tend to surface later, when customers
are sent reminders that disagree with their statements, so check the procedure output and not
only the table data.
Does MySQL treat empty strings as NULL like Oracle?
No, and this is the one difference no type mapping can fix. Oracle states that it "treats a character value with a length of zero as null", so an Oracle database has never held an empty string. MySQL stores '' as a value distinct from NULL. The migrated data is fine, because there were no blanks to migrate. The change shows up after cutover, when the same application code that wrote '' into Oracle and got NULL now writes '' into MySQL and gets a blank.
Three things drift from there. Reports that count missing values with IS NULL start to undercount. Columns declared NOT NULL, which in Oracle quietly kept blanks out too, start accepting them. And unique indexes on optional text columns, where Oracle allowed many NULLs, now allow one blank and reject the second. The fix is a decision per column, enforced in MySQL with a CHECK constraint, which MySQL has enforced since 8.0.16.
How do I convert Oracle sequences to MySQL?
MySQL has no CREATE SEQUENCE, so the usual Oracle pattern of a sequence plus a BEFORE INSERT trigger
becomes an AUTO_INCREMENT column, and the trigger is dropped. After the load, set every
AUTO_INCREMENT above the highest loaded value with ALTER TABLE t AUTO_INCREMENT = n.
A sequence shared by several tables, common in older billing schemas, has no direct equivalent and
needs a single-row counter table updated in a transaction, or keys generated by the application.
A missed reset is easy to spot, just not in testing. The first real insert after cutover collides with a loaded key, and the symptom is the order or signup endpoint returning errors on a Monday morning. Have something checking your API endpoints every 30 seconds through the cutover window, so the page reaches you before it reaches customers.
Why do table and column names break after the move?
Oracle stores unquoted identifiers in upper case, so a converter emits ORDERS and CUSTOMER_ID.
MySQL on Linux keeps table names exactly as created and compares them case sensitively, because the
default for lower_case_table_names on Unix is 0. Application SQL written as
select * from orders then fails. AWS asks for the setting to be 1 on RDS and Aurora
targets, and MySQL 8.4 says it "can only be configured when initializing the server". Set it in the
parameter group before you create the instance, or lower-case every name in the DDL.
How do I verify an Oracle to MySQL DDL conversion?
Run the checks on Oracle before the load, not on MySQL after it. Most of them read the data dictionary rather than scanning tables, so the whole list takes about an hour on a schema of any size. Replace APP with the schema owner and t and col with the table and column you are checking.
| Risk | Run this on Oracle | How to read the answer |
|---|---|---|
| Bare NUMBER columns | SELECT table_name, column_name FROM all_tab_columns WHERE owner = 'APP' AND data_type = 'NUMBER' AND data_precision IS NULL; | Each row is a column whose MySQL type is your decision, not the converter's. Profile it with the next query before choosing a DECIMAL. |
| How many decimals are real | SELECT COUNT(*), MAX(ABS(col)) FROM t WHERE col <> TRUNC(col, 2); | A zero count means two decimals are enough. A non-zero count means the extra digits are data. The maximum tells you how many integer digits to keep. |
| DATE columns that carry a time | SELECT COUNT(*) FROM t WHERE col <> TRUNC(col); | Any non-zero count proves the times are real and MySQL DATE would destroy them. Use DATETIME on every Oracle DATE regardless, because new rows will carry times too. |
| Dates below MySQL's range | SELECT COUNT(*) FROM t WHERE col < DATE '1000-01-01'; | Oracle accepts dates back to 4712 BC. MySQL supports DATETIME from 1000-01-01. Placeholder dates such as year 1 or 100 need to become NULL or a real date. |
| Timestamps past six digits | SELECT table_name, column_name, data_scale FROM all_tab_columns WHERE owner = 'APP' AND data_type LIKE 'TIMESTAMP%' AND data_scale > 6; | These columns will round. Harmless for reporting, risky if the column is part of a unique key, where two distinct values can round to the same one. |
| More than one time zone | SELECT TO_CHAR(col, 'TZR'), COUNT(*) FROM t GROUP BY TO_CHAR(col, 'TZR'); | One row back means one zone, and a plain UTC DATETIME is safe. Several rows mean the zone carries meaning and needs its own column. |
| Integer keys that outgrow the target | SELECT table_name, column_name, data_precision FROM all_tab_columns WHERE owner = 'APP' AND data_type = 'NUMBER' AND data_scale = 0 AND data_precision >= 10; | NUMBER(10) to NUMBER(18) needs BIGINT, and NUMBER(19) and above needs DECIMAL. A converter that picks INT on precision 10 fails months later on the first large value. |
| Case or accent pairs in unique keys | SELECT NLSSORT(col, 'NLS_SORT=BINARY_AI'), COUNT(*) FROM t GROUP BY NLSSORT(col, 'NLS_SORT=BINARY_AI') HAVING COUNT(*) > 1; | BINARY_AI is Oracle's accent and case insensitive sort, a close stand-in for MySQL's default collation. Any row returned is a pair Oracle kept apart and MySQL will call a duplicate. |
| NOT NULL text that will accept blanks | SELECT table_name, column_name FROM all_tab_columns WHERE owner = 'APP' AND nullable = 'N' AND data_type IN ('VARCHAR2', 'CHAR', 'NVARCHAR2'); | Oracle turns an empty string into NULL, so NOT NULL also kept blanks out. In MySQL it does not. Add CHECK (col <> '') where blanks were never meant to exist. |
| Tables too wide for a MySQL row | SELECT table_name, SUM(CASE WHEN data_type IN ('VARCHAR2','NVARCHAR2','CHAR','NCHAR') THEN char_length * 4 ELSE 0 END) FROM all_tab_columns WHERE owner = 'APP' GROUP BY table_name ORDER BY 2 DESC; | A rough utf8mb4 byte budget for the string columns of each table. Anything near 65,535 fails with error 1118, so move the longest strings to TEXT first. |
| Where each AUTO_INCREMENT starts | SELECT sequence_name, last_number FROM all_sequences WHERE sequence_owner = 'APP'; | With caching, last_number runs ahead of the values handed out, so it is a safe floor. Set each AUTO_INCREMENT to the larger of this and MAX(id) plus one. |
After the load, reconcile on values rather than counts. For each table compare the row count, the SUM of every numeric column, the MIN and MAX of every date, and the number of rows whose time is not midnight. Then insert one test row per table inside a transaction you roll back, to prove each AUTO_INCREMENT starts in the right place. A green row-count report is the most misleading artifact in this kind of project, because it passes on a migration that dropped every time of day.
Two pieces of scope belong in the plan rather than in a surprise. PL/SQL packages, procedures and
triggers need a code converter or a person, and SCT's extension pack emulates several Oracle
functions inside the target. The other is ordering: Oracle sorts NULLs last in an ascending ORDER BY
and MySQL sorts them first, so any paginated screen that orders on a nullable column shows a
different first page. Add an explicit expression such as ORDER BY col IS NULL, col where
it matters.
The tool comparison for this route, with what each one bills by, is on our Oracle to MySQL migration tools page. If the target is still undecided, the same source is covered for PostgreSQL in Oracle to PostgreSQL migration tools, with the procedural side in converting PL/SQL to PostgreSQL, and for a warehouse in the Oracle to Snowflake data type mapping. Another commercial engine into the same target is mapped in convert SQL Server data types to MySQL, and the budget model for any of these programs is in what a data migration really costs.
Declare the MySQL types once and keep them in sync with Oracle
Map the columns with DATETIME for DATE and DECIMAL where Oracle had NUMBER, run the backfill, then let the same mapping run incrementally with retries, alerts and per-record logs. From $49 a month, never metered by rows.
The live demo needs no card, and Starter is $49 a month.