On this page
The migration in one paragraph
Inventory the schema and the application SQL, map every column type, then load the data with pgloader for a one-time move or with AWS DMS or Debezium when MySQL keeps taking writes. Keep the target in sync from the binary log, validate with row counts, checksums and real queries, and cut over in a short write freeze. Case-insensitive collations, zero dates, unsigned integers, ENUMs and AUTO_INCREMENT cause most failures. MySQL 8.0 moved to Oracle Sustaining Support on 21 April 2026, which is a common trigger, but upgrading to 8.4 LTS is the smaller change if a supported version is the only goal.
Decide whether to migrate, and why now
Oracle's end-of-life notice says MySQL 8.0 has been covered under Sustaining Support since 21 April 2026, and recommends MySQL 8.4 LTS or 9.7 LTS. LTS releases follow Oracle Lifetime Support: five years of premier support plus three of extended support. If a supported version is the only goal, upgrading to 8.4 is a far smaller project than changing engines.
Migrate when there is a reason beyond preference: you need extensions such as PostGIS or pgvector, richer SQL for reporting on transactional data, a permissive licence, or a platform standard. If the deadline is the problem rather than the engine, patched builds of MySQL 8.0 from OSSeva buy time while you plan, and the end-of-life options are laid out in the MySQL end-of-life article. The rest assumes you have decided to move.
Inventory the schema, the SQL and the write rate
Start by counting what is actually in the schema. A column-type census tells you how many of the hard cases you have:
SELECT data_type, column_type, COUNT(*) AS n
FROM information_schema.columns
WHERE table_schema = 'appdb'
GROUP BY data_type, column_type
ORDER BY n DESC;Then list what a schema-only tool will not move: stored procedures, functions, triggers, events and views. pgloader's documentation states that triggers are not migrated. Collect the application SQL from the ORM and the slow query log, and record the server's sql_mode, character set and collation. Measure the largest tables and the sustained write rate, because they decide whether a bulk-load window is acceptable or you need change data capture.
Map the types and behaviours that differ
The table covers the differences that most often break a migration. Tools apply defaults; the point is to know where you are overriding them.
| MySQL | PostgreSQL | What to check |
|---|---|---|
| AUTO_INCREMENT | GENERATED ... AS IDENTITY (or serial) | Reset each sequence past the loaded maximum or the first insert collides. pgloader resets sequences by default. |
| TINYINT(1) | boolean | pgloader casts it to boolean. Confirm no column stores values above 1. |
| INT UNSIGNED, BIGINT UNSIGNED | bigint; numeric(20,0) if needed | PostgreSQL has no unsigned types. Widen the type, add CHECK (col >= 0), and use numeric where values can pass 9223372036854775807. |
| ENUM | CREATE TYPE ... AS ENUM, or text with a CHECK | PostgreSQL enums can gain and rename values but cannot lose them. A lookup table is more flexible. |
| SET | text[] or a join table | pgloader gives no default rule for SET. |
| DATETIME, TIMESTAMP | timestamp or timestamptz | MySQL TIMESTAMP is UTC-based, DATETIME is not zone-aware. pgloader maps both to timestamptz; choose per column. |
| '0000-00-00' zero dates | NULL | PostgreSQL rejects them. pgloader converts them to NULL by default; decide whether NULL is right. |
| utf8mb4_0900_ai_ci (default) | case-sensitive text; citext or an ICU collation | MySQL's default collation is case- and accent-insensitive. PostgreSQL comparisons are case-sensitive unless you choose otherwise. |
| ON UPDATE CURRENT_TIMESTAMP | a trigger | No equivalent column attribute. |
| INSERT ... ON DUPLICATE KEY UPDATE | INSERT ... ON CONFLICT DO UPDATE | Rewrite in application code and ORM calls. REPLACE INTO has no direct equivalent. |
| Backtick identifiers | Double-quoted identifiers | pgloader downcases identifiers by default, so quoted mixed-case names in application SQL will not match. |
Handle collation and case sensitivity deliberately
This is the failure that reaches production. MySQL 8's default collation, utf8mb4_0900_ai_ci, compares 'a' and 'A' as equal, and treats accented letters as equal to unaccented ones. PostgreSQL's default comparison is case-sensitive. A login lookup on an email column, a unique constraint on a username and a WHERE name = 'smith' all change meaning.
You have three options. The citext type compares case-insensitively and makes primary keys and unique constraints case-insensitive too, but its folding depends on the database's LC_CTYPE and it is slower than text. A nondeterministic ICU collation handles more Unicode cases, but PostgreSQL's documentation lists trade-offs: slower comparisons, no B-tree deduplication, and restrictions on pattern matching that depend on the version. An expression index on lower(column) works where you control the queries. Whichever you choose, search the existing data for values that differ only by case: loading them into a case-sensitive unique index succeeds, and the application then creates duplicates it used to reject.
Choose the tool for the data move
Three tools cover most migrations. They differ mainly in whether they can keep the target in sync while MySQL still takes writes.
| pgloader | AWS DMS | Debezium | |
|---|---|---|---|
| Best for | One-time load with a write freeze | Full load plus ongoing replication into PostgreSQL on or off AWS | Replication through Kafka when Kafka already exists or several consumers need the changes |
| Schema conversion | Yes: tables, indexes, foreign keys, sequences, casting rules | Creates tables and primary keys only. Use DMS Schema Conversion for the rest (MySQL to Aurora or RDS PostgreSQL) | Sink can create tables and add columns, not change types or keys |
| Change data capture | Not documented. Treat it as a bulk loader | Yes, from the binary log | Yes, from the binary log |
| Watch for | Views and triggers are not migrated | No cascade events, no computed-column changes, AUTO_INCREMENT not migrated, DROP TABLE and RENAME TABLE unsupported in CDC | Needs Kafka and Kafka Connect, and a binlog that is not purged while the connector is stopped |
pgloader for the one-time load
pgloader reads a MySQL database, converts the schema, builds indexes and foreign keys, loads the data in parallel and resets sequences. Its default casting rules turn tinyint(1) into boolean, widen unsigned integers, map datetime and timestamp to timestamptz and convert zero dates to NULL. A load file makes the choices explicit and repeatable:
LOAD DATABASE
FROM mysql://migrator@mysql-host/appdb
INTO postgresql://migrator@pg-host/appdb
WITH include drop, create tables, create indexes, foreign keys, reset sequences;Rehearse on a copy of production and time each phase. Where a write freeze of a few hours is acceptable this may be the whole data move; otherwise let CDC carry the changes that arrive during the load.
Keep the target in sync with change data capture
Both DMS and Debezium read the MySQL binary log, so the source needs row-based logging and enough retention to outlast the load. For DMS, AWS lists binlog_format = ROW, binlog_row_image = FULL, a retention of at least a day, and an account with REPLICATION CLIENT, REPLICATION SLAVE and SELECT:
[mysqld]
server_id = 101
log_bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL
binlog_expire_logs_seconds = 604800DMS. Create a task in "Migrate existing data and replicate ongoing changes" mode. Do not use a lagging read replica as the source: AWS warns of possible data loss when replica lag means a transaction commits on the primary before the task starts but reaches the replica after. Because DMS does not create secondary indexes or defaults, build the schema first with DMS Schema Conversion or pgloader.
Debezium. The MySQL connector takes a consistent snapshot, then streams the binary log. If the saved position is purged while the connector is down, it fails and asks for a new snapshot; snapshot.mode=when_needed lets it re-snapshot automatically. On the PostgreSQL side, the Debezium JDBC sink consumes the change events directly. Use insert.mode=upsert so replays are idempotent, and delete.enabled=true with primary.key.mode=record_key so deletes apply.
Validate before anyone switches
Run validation continuously while the target is syncing, not once at the end:
- Row counts per table, and aggregates or hashes over key ranges, because equal counts hide type coercion and timestamp shifts.
- Sequences: every identity or serial value must be above the table's maximum. After a load with explicit IDs, advance each one:
SELECT setval(pg_get_serial_sequence('orders', 'id'),
COALESCE(MAX(id), 1), MAX(id) IS NOT NULL)
FROM orders;- Case-insensitive collisions: values that differ only by case, in columns that were unique under MySQL.
- Real queries: replay the top statements from the slow log against both databases, compare results, then run
EXPLAIN ANALYZEon the PostgreSQL side. Plans differ, and indexes that suited the MySQL optimizer may not suit PostgreSQL. - Application tests against PostgreSQL, including text with emoji and accents, timestamps near daylight-saving changes and NULL ordering: MySQL sorts NULLs first in ascending order and PostgreSQL sorts them last.
Run the cutover
- Days before: freeze schema changes, lower DNS time to live on the database hostname, and rehearse the full runbook at least once on a copy.
- Confirm lag: replication lag is near zero and validation is clean.
- Stop writes: set MySQL read-only (
SET GLOBAL super_read_only = ON) and stop the application's writers. - Drain: wait until the pipeline reports zero lag and the last binary log position has been applied.
- Final checks: counts and key-range hashes, then advance every sequence.
- Switch: repoint the application at PostgreSQL through a connection pooler, and run the smoke tests.
- Open writes and watch error rates, slow queries and connection counts.
- Hold the old database read-only for a fixed period before decommissioning.
Decide rollback in advance. Once writes land on PostgreSQL, going back means either losing them or running replication the other way, for example Debezium's PostgreSQL source connector with the JDBC sink writing to MySQL. Many teams set a short rollback decision window instead.
Where migrations stall
- Case-insensitive assumptions in login, search and uniqueness logic.
- Zero dates and invalid dates in legacy rows. MySQL 8.4 enables
NO_ZERO_DATEby default, but old data and relaxed modes still carry them. - Stored procedures and triggers nobody inventoried.
- Binary log retention shorter than the initial load, which forces a restart.
- Connection handling: PostgreSQL runs a backend process per connection, so applications that open thousands of connections need a pooler.
- Operations: autovacuum, replication slots and WAL growth are new jobs for a team that only ran MySQL.
Getting help
AceMQ supports both engines under one contract, before and after the move: MySQL support for the source while it runs, PostgreSQL support for the target, and PostgreSQL consulting for schema review, cutover planning and tuning. If the source is Oracle or SQL Server instead, that is a different migration, and OSSeva handles it.
Sources: Oracle's MySQL end-of-life notice and the MySQL manual on releases, default collation and SQL modes; pgloader MySQL reference; AWS DMS on MySQL as a source, Schema Conversion and missing secondary objects; Debezium's MySQL connector and JDBC sink; PostgreSQL on numeric types, enums, identity columns, collations and citext, plus MySQL and PostgreSQL on NULL ordering and PostgreSQL's process-per-connection model.
Frequently asked questions
Is pgloader enough to migrate MySQL to PostgreSQL?
For a database that can tolerate a write freeze, often yes: it converts the schema, loads the data, builds indexes and foreign keys and resets sequences. It does not migrate triggers or views, and its documentation describes one-time loads, not ongoing replication, so use DMS or Debezium when MySQL keeps taking writes.
Can I migrate MySQL to PostgreSQL with no downtime?
Near zero. Load a snapshot, stream the binary log into PostgreSQL until the lag is nil, then stop writes briefly, drain the last changes and switch the application. The outage is the write freeze, not the data copy.
Does PostgreSQL have unsigned integers?
No. Use a wider signed type, add a CHECK constraint to keep values non-negative, and use numeric for BIGINT UNSIGNED columns that can exceed the bigint range.
Why do queries return different results after the migration?
Most often because of collation. MySQL's default utf8mb4_0900_ai_ci is case- and accent-insensitive, while PostgreSQL compares case-sensitively by default. Use citext, an ICU nondeterministic collation or lower() indexes where the application relied on the old behaviour.
When does MySQL 8.0 reach end of life?
Oracle moved MySQL 8.0 to Sustaining Support on 21 April 2026 and recommends upgrading to MySQL 8.4 LTS or 9.7 LTS. Upgrading is a smaller change than migrating engines if a supported version is the only goal.