Moving from MySQL to PostgreSQL¶
db:migrate-from-mysql moves an application's data from its MySQL database into the PostgreSQL
(or TimescaleDB) database its migrations built. The source can be MySQL or MariaDB: every test of
the command runs against both.
# 1. Point the application at PostgreSQL and build the tables
php bin/pramnos migrate
# 2. See what would move
php bin/pramnos db:migrate-from-mysql --source=mysql://user:password@host:3306/app --dry-run
# 3. Move it
php bin/pramnos db:migrate-from-mysql --source=mysql://user:password@host:3306/app --phase=full
The source can also be 'migration_source' => 'mysql://…' in app/app.php.
The data is half of a move; the SQL is the other. An application whose code holds
MySQL-written SQL can run it on PostgreSQL while it is converted, with
'database' => ['translate_mysql' => true]: see
MySQL's SQL on PostgreSQL.
The target is what the migrations made¶
The PostgreSQL tables come from the framework's migrations and the application's, so they are often typed better than the MySQL tables they replace. Every value is converted to what its target column takes:
| MySQL holds | The target column is | It arrives as |
|---|---|---|
1 / 0 in a tinyint(1) |
boolean |
true / false |
a unix time in an int |
timestamptz, timestamp, date |
the moment, in UTC |
0000-00-00… |
a date or a time | NULL |
JSON in text |
json / jsonb |
the JSON; invalid JSON as NULL, counted |
| bytes | bytea |
the bytes |
| bytes that are not UTF-8, or NUL bytes | text | valid UTF-8, without the NULs |
'' or non-numbers |
a number | NULL; a non-number counted |
A value that had to become NULL because it was not what its column takes is counted in the
report. A table's rows go in column by name. A column only MySQL has is not moved, and the dry
run names it.
Tables in a schema. MySQL flattens the framework's schemas into prefixed names:
authserver_things there is authserver.things here. The table prefix on each side is taken off
before the names are matched; --source-prefix names the source's when it differs.
Columns the target generates. A key the migrations made GENERATED ALWAYS AS IDENTITY
refuses an id it is given, so it is inserted with OVERRIDING SYSTEM VALUE. The ids are kept,
because the rows that point at them name them, and the identity continues past them. A column
the target computes (GENERATED ALWAYS AS (…) STORED) takes no value at all: it is left out of
the move, named in the dry run, and PostgreSQL computes it.
A table no migration makes (one the application created by hand in MySQL) is created from
its MySQL definition. Its types follow the mapping below, an AUTO_INCREMENT becomes an identity
column, and the primary key is the same. Once its rows are in, it gets MySQL's other indexes,
unique keys and foreign keys, with their ON DELETE / ON UPDATE rules:
- An index over a prefix (
name(50)) covers the whole column. A unique one stays unique, which the prefix already guaranteed. - A
FULLTEXTorSPATIALindex, or one over an expression, has no plain PostgreSQL equivalent. The dry run names it, and it is not made. - An index or key is named
<table>_<MySQL name>. PostgreSQL keeps 63 bytes of a name, so a longer one is cut to 54 and ends in_and 8 hex characters of a hash of the whole name. Two names that share their first 63 bytes stay two. - A foreign key points at wherever the referenced table went: one the migrations made, or one made in the same run. The table loads after the tables it points at.
- A foreign key that the moved rows break is added
NOT VALID, so it holds for new rows, and the run prints theVALIDATE CONSTRAINTto run once those rows are dealt with. With--null-orphans, the references that can be NULL are set to NULL first, and the key is then validated. - A run that stops after making a table and before its indexes and keys leaves it marked, in
pramnos_migration_progress, as made by this tool. The next run finishes it, and the dry run says so. A table the migrations made carries no such mark and is never touched.
| MySQL | PostgreSQL |
|---|---|
int unsigned |
bigint: the range fits |
bigint unsigned |
bigint: a numeric could be neither an identity column nor a foreign key's target. The dry run counts the rows above its range |
tinyint, smallint, mediumint, int |
smallint, smallint, integer, integer |
decimal(p,s), float, double |
numeric(p,s), real, double precision |
datetime, timestamp, date, time, year |
timestamp, timestamp, date, time, smallint |
char(n), varchar(n) |
varchar(n) — PostgreSQL's char pads what it reads back with spaces, MySQL's does not, and PHP would see 'gr ' where it had 'gr' |
text kinds, enum, set |
text |
blob kinds, binary, varbinary |
bytea |
json, bit(1) |
jsonb, boolean |
An enum becomes text with a CHECK that keeps its list. The list also takes '', because
outside strict mode MySQL stores '' for any value not on the list, and old rows hold it. A set
stays plain text, since any combination of its words is a value.
MariaDB stores a JSON column as longtext with a json_valid() check; the check is read and
the column is created as jsonb all the same. Defaults are kept where PostgreSQL can say the same
thing: the current time (CURRENT_TIMESTAMP, or MariaDB's current_timestamp()), a number, a
literal, bytes and a bit. A binary(n) default is the bytes MySQL stores, padded with zero bytes
to its length (DEFAULT '' is eight of them in a binary(8)), as '\x…'::bytea. A bit(1)
default is true or false. A zero date and an expression PostgreSQL has no word for are left
out.
How the rows move¶
- One snapshot. Every table is read in one MySQL transaction
(
START TRANSACTION WITH CONSISTENT SNAPSHOT), so rows that point at each other were read at the same moment. Reads are streamed, not loaded whole. - In batches, without duplicates. Rows go in with
INSERT … ON CONFLICT DO NOTHING,--batch-sizeat a time (5,000 by default, fewer for a wide table). A batch also stops at--batch-mbmegabytes of values (32 by default), so a table of large BLOBs goes a few rows at a time and the PHP process stays near that size. A read by key range starts with one row to weigh, and sizes each next range by what the last one weighed. A second run adds only what the first did not. A move that stopped half way is run again. - A refused batch costs only its bad rows. The batch is tried again row by row, and each row the target refuses is counted, with the first errors shown.
- Parents first. Tables load in the order of the target's foreign keys. Tables that point at each other in a ring load last and are named.
- Keys off where allowed. With a superuser on the target, the load runs with
session_replication_role = replica, so neither keys nor triggers are checked as rows arrive. The rows whose references then name nothing are counted afterwards, and--null-orphanssets them toNULLwhere the column takesNULL. Without a superuser, or with--keep-constraints, the keys are checked and such a row is refused. - Indexes out of the way, on request. Loading millions of rows into a table with half a
dozen indexes keeps each of them up row by row.
--defer-indexes(forfullandoperational) drops a migration-made table's plain indexes for the load and makes them again after.- Each definition is recorded in
pramnos_migration_progressbefore its index is dropped. A load that dies leaves what is needed, and the next run of any phase makes them again, with or without the option. - Unique indexes and those behind a constraint stay, because
ON CONFLICTand the keys need them. - The backfill never sets indexes aside: the application is already reading the table.
- Each definition is recorded in
- Sequences continue. Every sequence behind a moved column is set past the highest id, so the application's next row does not collide with a moved one.
The command exits 1 when any row was refused.
Values the target cannot hold as they are are converted, never guessed, and every one is counted in the result:
- MySQL's zero date (
0000-00-00) becomes NULL. In a column that takes no NULL it would refuse its row, so--zero-date='1970-01-01 00:00:00'names what it becomes there instead. Such a table moves through PHP, whatever the transport. - A NUL byte in text is dropped, since PostgreSQL text cannot hold one. The result counts the values that had one.
Text, character sets and double encoding¶
The source is read in utf8mb4, which the migrator sets on the connection itself rather than
leaving to the client library's default. Greek, emoji and every other script arrive as they are.
A column that is latin1 (or any other character set) in MySQL is converted to UTF-8 by MySQL as
it is read, and the dry run names it.
Double-encoded text is the one problem a correct read cannot fix. An application that wrote
UTF-8 through a latin1 connection had each byte stored as a character of its own, so Ελλάδα
sits in the table as Ελλάδα. MySQL returns it exactly as stored, and it would move like
that. The dry run reads up to 2,000 rows of each table and names every column where it finds
such values, with one of them repaired:
legacy: 37 of 2000 sampled value(s) look double-encoded — "Ελλάδα" would be "Ελλάδα";
--repair-encoding=greek.legacy repairs them
--repair-encoding=table.column,… repairs those columns value by value. Each character is turned
back into the byte latin1 (Windows-1252) made of it, and the value is replaced only when the
result is valid UTF-8 that differs from it. So café in a genuine latin1 column, or proper
Greek, is left alone. A table with a column to repair moves through PHP, even with
--transport=fdw. Give --phase=verify the same option, so it compares the values as they were
written.
What MySQL filled in out of strict mode¶
A MySQL server running without strict mode, as many production servers do, fills a NOT NULL
column that an insert leaves out with '' or 0. PostgreSQL refuses the insert. The application's
code was never told it relied on this. The dry run reads the server's sql_mode and, out of
strict mode, counts the target's columns that rely on it: NOT NULL, no default, and no default
in MySQL either. Keys are not counted, since the application always gives a key.
MySQL runs out of strict mode: 1809 NOT NULL column(s) with no default take an insert that leaves
them out there (as '', 0 or a zero date), and refuse it here. --implicit-defaults gives 1650 of
them that default; the rest — dates — need one chosen in the migration: users.born, …
--implicit-defaults gives those columns MySQL's default: '' for text, 0 for a number,
false for a flag, an empty bytea. A date's default in MySQL is the zero date, which PostgreSQL
has no value for, so it is named for the migration to decide.
Text kept in a blob¶
An old schema often keeps text, or JSON, in a blob. Moved as bytea, it is bytes the
application then has to decode. The dry run reads up to 200 rows of each table's blob columns and
names those whose values are all text:
doc keeps text in a blob: 200 of 200 sampled value(s) are UTF-8, all of them JSON — jsonb in the
migration takes them as they are
Make such a column text or jsonb in the migration, and the values arrive as the text they are.
For a table the command makes, --blob-as-text=table.column,… creates it as text. A blob of
real bytes is not named. Write those through Pramnos\Database\Binary; see the
Database API guide.
Comparisons without regard to case¶
MySQL's _ci collations (utf8mb4_general_ci, utf8mb4_0900_ai_ci, MariaDB's
utf8mb4_uca1400_ai_ci) compare text without regard to case, and PostgreSQL compares it exactly.
After the move, WHERE email = 'Maria@example.com' no longer finds maria@example.com, and a
unique key that refused the second spelling now takes it. The dry run names every indexed text
column where that changes something:
email is unique without regard to case in MySQL (utf8mb4_general_ci): here 'A' and 'a' are two
values, both allowed, and = matches one — citext, or lower() in the index and the query, keeps
MySQL's answer
What to do is the application's decision, made in its migrations before the move:
citextkeeps MySQL's behaviour column by column.- A unique index on
lower(email), with the lookups writtenlower(email) = lower(?), keeps it in the queries that matter.
A column the target made citext is not named, and neither is one whose MySQL collation is
binary.
Dates, times and zones¶
MySQL has two kinds of moment, and they move differently:
TIMESTAMPis a moment. MySQL stores it in UTC and shows it in the session's zone. The source is read with the session in UTC, asmysql_fdwreads it, so aTIMESTAMParrives as the moment it is, whatever the server's zone.DATETIMEis a wall-clock time. It has no zone: it is whatever the application's clock said. It names a moment only once a zone is given, so going into atimestamptzit is read in--source-timezone, which defaults to the application's zone (date_default_timezone_get()).
| Source | Into timestamptz |
Into timestamp / date |
|---|---|---|
DATETIME, DATE |
the moment it names in --source-timezone |
as it was |
TIMESTAMP |
the moment | the wall-clock time in --source-timezone |
a unix time in an int |
the moment | the wall-clock time in --source-timezone |
The dry run lists every DATETIME that goes into a timestamptz, and warns when the MySQL
server's own zone is not --source-timezone:
DATETIME values going into timestamptz are read as Europe/Athens time: orders.created
The MySQL server runs at UTC+00:00, Europe/Athens is at UTC+03:00: if the application wrote them
in the server's time, name that zone with --source-timezone.
That is the one question to settle before a move: which clock wrote these values. An
application that set its own zone wrote in that zone; one that used NOW() in SQL wrote in the
server's.
The same zone is used where a phase compares with a DATETIME. The cutover finds changed rows by
their change column, and the backfill cuts its windows, in that zone. Compared in the wrong zone,
a zone behind UTC would miss every change made in the last hours.
What the dry run tells you¶
5 table(s), in this order. Load with keys off: yes
users → users ~3 rows, 7 column(s)
not moved, the target has no such column: legacy_col
orders → orders ~3 rows, 4 column(s)
only_mysql → only_mysql (created: no migration makes it) ~2 rows, 3 column(s)
strict_t → strict_t ~1 rows, 1 column(s)
the target requires, and the source has not: must_have — every row will be refused
Triggers, views, procedures, functions and events are not moved. They carry the application's logic in MySQL's dialect, and making them again belongs to the migrations. The dry run names each, with whether the target already has one of that name:
Not moved — MySQL logic the migrations are to make again:
trigger orders_bi (on orders) — the target has none
view big_orders — the target has one of that name
A column the target requires (NOT NULL, no default) that MySQL does not have refuses every row:
give it a default, or a value, before the move. A target table with compressed TimescaleDB chunks
refuses rows into them: decompress first.
Options¶
| Option | |
|---|---|
--source |
mysql://user:password@host:port/name |
--dry-run |
Report, change nothing |
--phase |
full, operational, cutover, backfill, verify — see below |
--tables, --exclude-tables |
Source tables to move, or to leave, comma-separated |
--batch-size |
Rows per INSERT (default 5000) |
--batch-mb |
Most megabytes of values per batch, whatever the row count (default 32) |
--source-prefix |
The source tables' prefix, when it is not the application's |
--zero-date |
What MySQL's zero date becomes in a column that takes no NULL (default: NULL, and the row is refused) |
--implicit-defaults |
Give the target the defaults MySQL filled in out of strict mode: '', 0, false |
--blob-as-text |
A table made here gets these blob columns as text, not bytea — table.column |
--repair-encoding |
Repair double-encoded text in these columns, table.column — see Text, character sets and double encoding |
--source-timezone |
The zone the source's DATETIME values were written in (default: the application's) — see Dates, times and zones |
--keep-constraints |
Check the target's keys as rows arrive |
--null-orphans |
After a load with keys off, set references to nothing to NULL |
--changed-column |
cutover: the column a changed row is told by |
--with-deletes |
cutover: delete the rows deleted in MySQL since the copy |
--append-only |
cutover: tables without a change column read only for new rows, not compared |
--verify |
full, cutover: compare every row with MySQL once the phase is done; backfill: digest each window as it is written |
--defer-indexes |
full, operational: drop the target's plain indexes for the load and make them again after |
--no-snapshot |
full, operational: read each table a range of keys at a time, no transaction held open |
--time-column |
backfill: the column windows are cut on |
--chunk-interval |
backfill: a window's length (default 1 month) |
--throttle-ms |
backfill: a pause after each batch (default 50) |
--compress-after-chunk |
backfill: compress a window's chunks once it is done |
--until |
backfill: where the newest window ends (default: the cutover's moment) |
--restart |
backfill: forget the windows done; operational: copy again the tables an earlier run finished |
--transport |
php (default), or fdw: rows moved by PostgreSQL through mysql_fdw |
--fdw-host |
fdw: the MySQL host as the PostgreSQL server reaches it |
-v |
Report each batch |
A live production move, in three phases¶
--phase=full moves everything while the application is stopped. That suits a development copy,
or a database small enough to move in a maintenance window. A large production database moves
in three phases, with the application stopped only for the middle one:
# 1. While the application runs on MySQL: the operational tables
php bin/pramnos db:migrate-from-mysql --source=… --phase=operational --exclude-tables=devicedata,itemlogs
# 2. Writes stopped (maintenance mode), a few minutes: what changed since, checked, then switch the app
php bin/pramnos db:migrate-from-mysql --source=… --phase=cutover --exclude-tables=devicedata,itemlogs --with-deletes --verify
# 3. The application runs on PostgreSQL: history, in the background
php bin/pramnos db:migrate-from-mysql --source=… --phase=backfill --tables=devicedata,itemlogs \
--time-column=date --chunk-interval="1 month" --compress-after-chunk
The progress is kept in the target, in pramnos_migration_progress: one row per table, phase
and window. Moments there are Unix times.
Operational. The same copy as full, in one snapshot, and the snapshot's moment (T0) and
each table's highest integer id are recorded. Leave the large append-only tables (readings,
logs, mail) to the backfill.
A copy that stops is taken up where it stopped. Run the same command again:
- A table the earlier run finished is not read again.
- The table it was copying is read on past the highest id already in the target, when its key is one integer column. It keeps the moment (T0) and highest id of the attempt that began it, so the cutover reads changes from the earlier moment, which takes in everything since.
--restartcopies every table again.
A snapshot is one MySQL transaction held open for the whole copy. On a busy source that is hours
of undo history kept for it. --no-snapshot reads each table instead a range of keys at a time
(WHERE (key) > (last) ORDER BY key LIMIT n), each range its own statement, so no transaction is
held open. The price is that tables are read at different moments, and a row may point at one
read before or after it. T0 and the highest ids are taken before the first read, so the cutover
still catches everything written since, and the verification after it finds whatever was not
caught.
Cutover. What changed in MySQL since each table's copy is written over the target, with
ON CONFLICT (primary key) DO UPDATE. A primary key of several columns works the same as one.
How a table is caught up depends on what it has:
- A change column (
updated_at, ormodified,last_modified,changed_atand the like, or the one--changed-columnnames). The rows changed at or after T0 are read, together with the rows past the highest id the copy saw. A Unix time in an integer column counts. - No change column. Every row is compared with the target's row of the same key, a range of keys at a time, and the rows that are missing or differ are written. This reads the whole table, so it suits the small and medium tables that make up the operational set.
- A big table that only ever grows (a log, a ledger), named in
--append-only. Only the rows past the highest id are read. The command says that rows which changed since the copy are not seen.
With --with-deletes, the target is walked a range of keys at a time, MySQL is asked which of
those keys it still has, and the rest are deleted. Memory stays at one range whatever the table's
size. A key whose text differs between the servers (a moment, a float) cannot be looked up across
them: the command names it and deletes nothing in that table.
A table without a primary key, or one the operational phase never copied, is named and left: copy
it again with --phase=full --tables=…. Sequences move past the highest id, as in full.
-v shows how each table was caught up.
Verify. --phase=verify, or --verify after full or cutover, checks that the target
holds what MySQL holds:
Compared with MySQL:
✓ users: 18204 row(s) in MySQL, 18204 here
✗ log: 2 row(s) in MySQL, 2 here, 1 different — e.g. key 1
✗ pairs: 3 row(s) in MySQL, 4 here, 1 MySQL has not — e.g. key 9, q
- Each table's rows are counted on both sides.
- Every source row is converted as the move converts it, and compared with the target's row of
the same key by a hash PostgreSQL computes for both. A flag that became a
boolean, adatetimethat became atimestamptzand JSON reformatted byjsonbtherefore compare equal. - The target is walked for rows MySQL no longer has.
A hypertable is verified by time window. Its rows usually arrive without MySQL's id, so
there is no key to compare by. --phase=verify --time-column=time --chunk-interval="1 month"
compares each window instead, from --until (default: now) back to the first row. Each window's
rows are counted on both sides and digested: the sum of a hash of every row, as the move writes
it, so the order rows come in does not matter. The windows that differ are named:
✗ metrics: 2401003 row(s) in MySQL, 2401002 here
27 window(s) compared
window 2026-03-01 00:00 → 2026-04-01 00:00: 32 in MySQL, 31 here
window 2026-04-01 00:00 → 2026-05-01 00:00: 30 in MySQL, 30 here, different
A window with fewer rows here often means MySQL held two rows the target's unique key takes as one. "Different" with equal counts means a value did not arrive as written.
Nothing is changed, and the command exits 1 on any difference, naming up to five keys. Run it
with writes stopped: rows the application writes to PostgreSQL afterwards count as differences.
So does a reference that --null-orphans set to NULL. A table without a primary key is counted
but not compared.
Backfill. A table's history moves in windows of --chunk-interval on --time-column, newest
first. The first window ends at --until, by default the moment the cutover finished, so what
the application has written to PostgreSQL since is not touched.
- A window done is remembered. A backfill that stops — a deploy, a failure, a
Ctrl-C — continues from the windows not done.
--restartforgets them, and the rows already there are left as they are (ON CONFLICT DO NOTHING). The windows are counted from--until, so keep it the same between runs. - Into a hypertable:
- The compression policy is removed for the backfill and put back after, with its
compress_after. A run that stops leaves it remembered, and the next run puts it back. - Chunks already compressed in a window are decompressed before rows go in, since TimescaleDB refuses rows into a compressed chunk.
- With
--compress-after-chunk, each window's chunks are compressed as soon as the window is done, so the disk does not fill with uncompressed history.
- The compression policy is removed for the backfill and put back after, with its
- The time column needs an index in MySQL. A window is a range on it; without an index that
starts with it, every window reads the whole table, which for years of history means days.
The dry run with
--time-columnnames such a table, with theCREATE INDEXto run first. - Each window is checked as it is written. Its rows are counted on both sides, and a window
that lost rows is named at once, with the run exiting
1. With--verify, each window is also digested, as--phase=verifydigests it. That costs a second read of the window. - Beside live traffic.
--batch-sizerows at a time, and--throttle-msbetween batches, so WAL and replicas keep up. Rows of a past window land in past chunks, never in the chunk the application is writing to.
An interval that does not move back in time (0 days, or a typo) is refused before anything is
touched.
Faster: through mysql_fdw¶
With mysql_fdw installed on the PostgreSQL server, --transport=fdw lets PostgreSQL read
MySQL itself. Each table, or each backfill window, moves as one INSERT … SELECT from a foreign
table, and the rows never pass through PHP. For years of history that is the difference between
hours and days.
php bin/pramnos db:migrate-from-mysql --source=mysql://u:p@mysql.internal/app --phase=backfill \
--tables=devicedata --time-column=date --transport=fdw --fdw-host=10.0.0.5
- What the run makes, and drops. A foreign server, a user mapping and a schema of foreign
tables (
pramnos_mysql) are made for the run and dropped after it. MySQL is reached from the PostgreSQL server, not from PHP:--fdw-hostnames it as that server sees it, when that is not the--sourcehost. - The same conversions, in SQL. A flag becomes a boolean, a unix time a moment, a zero date
NULL, JSON only when it is JSON (IS JSON, PostgreSQL 16 and later), a number only when it is one. - PHP takes over where SQL cannot. Bytes that are not UTF-8, a NUL byte, or a value the
wrapper refuses fail the statement. That table, or that window, then moves through PHP, which
repairs them, and the report says so. Values made
NULLon the way are counted only through PHP. - No shared snapshot. Each table is read in its own MySQL transaction, not in one snapshot
for all of them. That is no loss for a backfill of history that is only appended to. For an
operational copy of tables that point at each other, prefer
--transport=php. - The cutover always moves through PHP. Its rows are few, and they are written over the
target, which
ON CONFLICT … DO UPDATEneeds PHP for here.
mysql_fdw is not packaged for every distribution. The framework's own test image builds it on
the pinned TimescaleDB (docker/timescaledb/Dockerfile), and that Dockerfile is a recipe for a
server of your own.