How migrations work
Tested with: zb migrate 0.1 · zb-migrate-agent 0.1 · PostgreSQL 17 and MySQL 8.4 sync/cutover end-to-end tests; copy for PostgreSQL 15-17, MySQL 8.0/8.4, Valkey 8, FerretDB 2.4, libSQL 0.24
A migration moves a database from a provider, a server, a laptop or a file into a Databasezy instance, verifies the copy and, for PostgreSQL and MySQL, keeps the target in sync until you cut over.
request ──► preflight ──► copy ──► verify ──► (optional) continuous sync ──► cutover ──► reportWhere the data flows
Section titled “Where the data flows”The copy runs as a Kubernetes Job inside the target instance’s namespace (zb-migrate-agent, a Rust wrapper
around the engines’ own tools). Source credentials live in a Secret in that namespace and are deleted when the
migration ends or expires after 24 hours. The control plane creates the migration record and reads progress events;
it never touches the data.
The job reaches your source from the cell’s egress addresses. Preflight prints them together with the place to
allow them for your provider (security group, IP access list, network restrictions). For --from local and file
uploads nothing connects to your machine: the CLI streams to a pre-signed upload URL.
The steps
Section titled “The steps”- Preflight (seconds). Offline, from the connection string: engine compatibility, the
provider preset (TLS mode, pooler endpoints, provider-private hosts), and whether continuous
sync is possible. Live, from the source: engine version, database size, extensions and, for continuous sync,
wal_level, the replication privilege and tables without a primary key (PostgreSQL) orlog_bin,binlog_format,gtid_modeand theREPLICATION SLAVEgrant (MySQL). Every item carries a machine code (neon.pooler_rewritten,rds.iam_auth,pg.replica_identity, …). Blocking items stop the migration before anything is written. - Copy.
pg_dump | pg_restore,mysqldump | mysql,SCAN+DUMP/RESTOREfor Valkey/Redis,mongodump | mongorestore, or a file restore. Progress is reported per table, collection or key count. - Verify. Row counts per table or collection, sequence /
AUTO_INCREMENTvalues, index count and a schema hash, compared between source and target. Mismatches are listed; a missing table or a short row count fails the migration. - Continuous sync (PostgreSQL and MySQL connection-string sources). PostgreSQL uses logical replication: a
publication on the source and a subscription on the target that starts exactly where the copy ended (the copy
reads the snapshot exported by the replication slot). MySQL uses GTID replication on a dedicated channel,
filtered to the migrated database. Lag is sampled every few seconds and shown in
zb migrate status; the migration becomes ready for cutover after two consecutive samples under 5 seconds. - Cutover when you run
zb migrate cutover. See what happens at cutover. - Report. Durations, sizes, verification results, sync lag and the measured cutover downtime are stored with the migration.
What happens at cutover
Section titled “What happens at cutover”Cutover is the only moment your application must not write. The agent measures it and reports it as the downtime.
- You stop writes to the source (maintenance mode, scale writers to zero, or revoke the app user) and run
zb migrate cutover <mig_id>. The CLI waits for the result. - Drain. The agent reads the source’s current position (PostgreSQL:
pg_current_wal_flush_lsn(); MySQL:@@GLOBAL.gtid_executed) and waits until the target has applied everything up to it (PostgreSQL: the slot’sconfirmed_flush_lsn; MySQL:WAIT_FOR_EXECUTED_GTID_SET). If the source keeps changing and the target cannot catch up within the timeout (120 s by default), the cutover is abandoned and sync keeps running; nothing has changed on either side. - Sequences (PostgreSQL). Logical replication does not carry sequence values, so the agent copies every
sequence’s value from the source and then raises each serial / identity sequence to at least
max(column). It never lowers one. MySQLAUTO_INCREMENTcounters follow the replicated rows and need no step. - Verify. Row counts, sequences, indexes and schema are compared again, now on a quiet source. A mismatch abandons the cutover (sync keeps running) and lists what differs.
- Stop replication. PostgreSQL: the subscription is dropped on the target, which also drops its replication
slot on the source (so no WAL is retained there), and the publication is removed. MySQL:
STOP REPLICAandRESET REPLICA ALLon the migration’s channel. From here on the target is independent. - Reverse sync (optional, PostgreSQL,
--reverse-sync). A publication on the target and a subscription on the old source, created while writes are still stopped, keep the old database current so you can switch back. Stop it later withDROP SUBSCRIPTION zb_mig_<id>_revon the old source andDROP PUBLICATION zb_mig_<id>_revon the target. - You point the application at Databasezy and resume writes.
zb migrate statusshows the downtime and each phase’s duration (drain, sequences, verify, stop, reverse).
How long it takes
Section titled “How long it takes”- Preflight: seconds.
- Copy: the speed of
pg_dump | pg_restore(or the engine’s equivalent) between your source and the cell, usually limited by the source provider’s throughput. The preflight estimate assumes a conservative 25 MiB/s plus 30 s of overhead;--createsizes storage at 1.5x the source. - Verify: one
count(*)per table on each side; minutes for large databases. - Cutover with sync: the drain plus the checks. In our end-to-end tests (two local PostgreSQL 17 and two MySQL 8.4 containers, a few thousand rows) the measured downtime was 0.5 to 1.4 s, most of it the verification. Expect more with many large tables (verification counts rows) and with a busy source that is not fully quiet.
- Cutover without sync: the whole copy, since writes must stop before it starts.
Provider presets
Section titled “Provider presets”The provider is detected from the hostname (or forced with the API’s source.provider). A preset rewrites the URL
into the one the tools need and adds provider-specific checklist items:
| Provider | What the preset does |
|---|---|
| Neon | -pooler endpoint -> direct endpoint, adds options=endpoint=<ep-id> and sslmode=require; branch and logical-replication notes |
| Supabase | Supavisor transaction pooler (:6543) -> db.<ref>.supabase.co:5432 as postgres; session pooler kept for copy, rewritten for sync; platform schemas excluded; IPv6 note |
| PlanetScale | ssl-mode=REQUIRED, --set-gtid-purged=OFF, foreign-key note; blocks continuous sync (no binlog access) |
| RDS / Aurora | TLS; IAM-auth warning when the URL has no password; RDS Proxy and Aurora reader endpoints flagged (readers block sync); binlog retention note |
| Upstash | redis:// -> rediss://; throughput note |
| Turso | libsql:// -> https://; missing authToken flagged |
| MongoDB Atlas | SRV host noted (each replica-set member must be allowed); shared-tier throughput note |
| Railway | TLS; *.railway.internal hosts blocked (use the TCP proxy) |
| Render | TLS; internal dpg-...-a hosts flagged |
| Fly.io | .internal / .flycast hosts blocked (WireGuard-only); public-IP or fly proxy + --from local guidance |
| Aiven | TLS; connection-pool URI and aiven_extras notes |
| Heroku | TLS; credential-rotation warning; blocks continuous sync |
Every preset also names where to allow the cell’s egress addresses.
Ways to start
Section titled “Ways to start”| Source kind | How |
|---|---|
| Provider or any reachable server | zb migrate --from <connection string> --to <instance> |
| Local server | zb migrate --from local --engine postgres --db myapp --to <instance> (runs pg_dump locally) |
| File | zb migrate --from ./app.sqlite --engine libsql --create (.sql, .dump, .sql.gz, .rdb, .sqlite, .db, .duckdb, .bson.tar, .parquet, .csv) |
| Another Databasezy instance | zb migrate --from inst_abc --to inst_def |
| API | POST /v1/orgs/{org}/instances/{id}/migrations (REST API) |