Migrating from PostgreSQL to MySQL: Tools, Type Mapping, and What You Give Up

7 min readPostgreSQL

Migrating from PostgreSQL to MySQL

This is the less-traveled direction, and it shows: the polished tooling mostly points the other way. pgloader, the workhorse of MySQL-to-PostgreSQL moves, only loads into PostgreSQL — it can't help you here. What remains is one maintained GUI tool, a couple of abandoned scripts you should skip, and a manual path that's more reliable than it sounds.

Worth being direct about motive first. The common good reasons to land on MySQL: your organization standardized on it, your ops team runs a large MySQL/Vitess or RDS-MySQL fleet and PostgreSQL is the odd one out, or you're consolidating onto managed infrastructure where MySQL expertise is deep. "MySQL is faster" is not a good reason in 2026 — both engines are fast, and you'll spend the presumed performance win rewriting features PostgreSQL gave you for free. Read the feature-loss list below before committing; for some schemas this migration is a weekend, for others it's a re-architecture.

Tooling Landscape (as of July 2026)

ToolStateVerdict
MySQL Workbench Migration Wizard (8.0.47)Maintained, officialThe main automated path; connects to PostgreSQL over ODBC, converts schema + copies data
Manual pg_dump + convert + LOAD DATAAlways worksMost control; right answer for large or gnarly schemas
SQLinesOpen-source converter, last meaningful updates 2023Useful for one-off DDL/SQL translation, don't lean on it for data
pg2mysql (Lightbox / pivotal-cf)Abandoned (pivotal-cf archived 2018)Skip
AWS DMS / cloud migration servicesMaintainedSensible for large RDS/Aurora-to-RDS moves with CDC-based cutover

MySQL Workbench Migration Wizard

Workbench connects to your PostgreSQL server through the psqlODBC driver, reverse-engineers the schema, converts DDL to MySQL dialect, lets you review and edit every conversion decision, then bulk-copies the data. For small-to-medium databases with mainstream types, it does most of the work. Two caveats: install and configure the PostgreSQL ODBC driver first (the wizard's most common failure is an ODBC connection error, not a migration error), and treat its type choices as proposals — review the generated DDL before accepting, especially around the types below.

The manual path

For anything the wizard chokes on: dump schema only (pg_dump --schema-only), rewrite the DDL by hand or with SQLines, create the MySQL schema, then export data as CSV (\copy table TO 'table.csv' CSV) and import with LOAD DATA LOCAL INFILE. CSV is the interoperability layer — it strips every engine-specific representation problem down to "does this string parse."

Type Mapping

PostgreSQLMySQLNotes
serial / identityINT AUTO_INCREMENT / BIGINT AUTO_INCREMENTSequences don't exist in MySQL; per-table auto-increment only
textTEXT / LONGTEXTMySQL TEXT caps at 64 KB — use LONGTEXT if unsure
booleanTINYINT(1)MySQL has no real boolean; true/false become 1/0
timestamptzTIMESTAMP — carefullyThe 2038 trap — see below
timestampDATETIMESafe range to year 9999, no timezone conversion
numericDECIMAL(m,n)MySQL requires explicit precision; PostgreSQL's unconstrained numeric doesn't map
uuidCHAR(36) or BINARY(16)No native type; BINARY(16) + UUID_TO_BIN() for index efficiency
jsonbJSONDifferent internals, similar function set; expression indexes replace GIN
text[] and other arraysJSON or a child tableNo arrays in MySQL — schema redesign, see below
enum typesENUM(...) inlinePostgreSQL enums are schema objects; MySQL enums are per-column
inet / cidrVARCHAR(45) or VARBINARY(16)No equivalent
intervalnothing cleanStore seconds as BIGINT or a string; rewrite arithmetic

The TIMESTAMP 2038 trap. MySQL's TIMESTAMP is still internally a 32-bit epoch value with a hard ceiling of January 19, 2038 — verified against MySQL's own bug tracker as still true in 8.4 and the 9.x series (as of July 2026). PostgreSQL's timestamptz happily stores dates far beyond that, and contract end dates, 30-year mortgages, and long retention windows already do. Audit before choosing: SELECT max(col) FROM t; on every timestamptz column. Anything at or past 2038 must become DATETIME — and since DATETIME has no timezone awareness, normalize everything to UTC during export (SET timezone = 'UTC' in your dump session) and enforce UTC at the application layer.

Arrays are the schema redesign. A text[] column has two honest translations: a JSON column (queryable with JSON_CONTAINS, indexable via multi-valued indexes on 8.0.17+) or a proper child table. If you ever join or filter on array elements, choose the child table; retrofitting relational integrity onto a JSON column later is worse than doing it now.

SQL Your Application Has to Change

  • RETURNING — gone in MySQL (MariaDB has it; MySQL does not). Rewrite INSERT ... RETURNING id as insert + LAST_INSERT_ID().
  • ON CONFLICT DO UPDATEON DUPLICATE KEY UPDATE (semantics differ: it fires on any unique key collision, not a named conflict target).
  • ILIKE — MySQL comparisons are case-insensitive by default under *_ci collations, so plain LIKE usually already behaves like ILIKE. The inverse trap: case-sensitive matching now needs BINARY or a _cs/_bin collation.
  • DISTINCT ON (...) → window function (ROW_NUMBER() OVER (PARTITION BY ...)) — MySQL 8.0+ has full window function support, so this is mechanical.
  • Partial indexes (CREATE INDEX ... WHERE ...) — don't exist. Nearest substitutes: functional indexes over NULLIF-style generated columns, or accept a full index.
  • Transactional DDL — gone. Every ALTER TABLE in a MySQL migration script commits implicitly and can't be rolled back. Your migration tooling (Rails, Flyway, whatever) will run fine but failed migrations leave half-applied state — plan for it.
  • TRUNCATE inside a transaction, deferred constraints, EXCLUDE constraints, custom operators/types, extensions (PostGIS → MySQL spatial is a real but smaller feature set; pgvector → MySQL 9 vector type is younger) — audit each explicitly.

CTEs and recursive CTEs are fine on MySQL 8.0+. So are check constraints (8.0.16+, actually enforced). The gap is narrower than PostgreSQL folklore says — but it is not zero.

Validation

Same discipline as any migration: identical aggregate queries on both engines, diff the output.

SELECT COUNT(*), SUM(CRC32(CONCAT_WS('#', id, email))), MIN(created_at), MAX(created_at)
FROM users;

CRC32(CONCAT_WS(...)) works on MySQL and on PostgreSQL 18+ (which added crc32() in 2025), making it a convenient shared checksum; on older PostgreSQL use md5() on both sides instead. Watch the columns where representation changed — booleans (t/f vs 1/0), timestamps (timezone normalization), anything that was an array. Mako connects to PostgreSQL and MySQL side-by-side, so you can keep one editor open per database and run the same validation query against both during cutover.

Common Mistakes

MistakeConsequenceFix
Reaching for pgloaderIt only loads into PostgreSQLWorkbench wizard or the manual CSV path
Mapping timestamptz to TIMESTAMP blindlyData past Jan 2038 won't fitAudit max() per column; use DATETIME + UTC discipline
Flattening arrays into comma-stringsUnqueryable dataJSON column or child table
Assuming LIKE is case-sensitiveMatching behavior silently changedKnow your collation; BINARY where sensitivity matters
Trusting transactional DDL habitsHalf-applied failed migrationsMake migration steps idempotent/re-runnable
Porting ON CONFLICT 1:1Fires on unexpected unique keysReview every upsert against all unique constraints

If you're still deciding rather than migrating, PostgreSQL vs MySQL covers the feature comparison in both directions. For the new connection code, see connecting to MySQL from Python and the rest of the connect-from series.

Mako connects to PostgreSQL and MySQL with AI-powered autocomplete — handy for running validation queries against both databases side-by-side during a migration. Try it free at mako.ai.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.