Skip to content

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 ──► report

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.

  1. 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) or log_bin, binlog_format, gtid_mode and the REPLICATION SLAVE grant (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.
  2. Copy. pg_dump | pg_restore, mysqldump | mysql, SCAN + DUMP/RESTORE for Valkey/Redis, mongodump | mongorestore, or a file restore. Progress is reported per table, collection or key count.
  3. Verify. Row counts per table or collection, sequence / AUTO_INCREMENT values, 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.
  4. 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.
  5. Cutover when you run zb migrate cutover. See what happens at cutover.
  6. Report. Durations, sizes, verification results, sync lag and the measured cutover downtime are stored with the migration.

Cutover is the only moment your application must not write. The agent measures it and reports it as the downtime.

  1. 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.
  2. 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’s confirmed_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.
  3. 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. MySQL AUTO_INCREMENT counters follow the replicated rows and need no step.
  4. 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.
  5. 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 REPLICA and RESET REPLICA ALL on the migration’s channel. From here on the target is independent.
  6. 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 with DROP SUBSCRIPTION zb_mig_<id>_rev on the old source and DROP PUBLICATION zb_mig_<id>_rev on the target.
  7. You point the application at Databasezy and resume writes. zb migrate status shows the downtime and each phase’s duration (drain, sequences, verify, stop, reverse).
  • 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; --create sizes 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.

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:

ProviderWhat the preset does
Neon-pooler endpoint -> direct endpoint, adds options=endpoint=<ep-id> and sslmode=require; branch and logical-replication notes
SupabaseSupavisor transaction pooler (:6543) -> db.<ref>.supabase.co:5432 as postgres; session pooler kept for copy, rewritten for sync; platform schemas excluded; IPv6 note
PlanetScalessl-mode=REQUIRED, --set-gtid-purged=OFF, foreign-key note; blocks continuous sync (no binlog access)
RDS / AuroraTLS; IAM-auth warning when the URL has no password; RDS Proxy and Aurora reader endpoints flagged (readers block sync); binlog retention note
Upstashredis:// -> rediss://; throughput note
Tursolibsql:// -> https://; missing authToken flagged
MongoDB AtlasSRV host noted (each replica-set member must be allowed); shared-tier throughput note
RailwayTLS; *.railway.internal hosts blocked (use the TCP proxy)
RenderTLS; internal dpg-...-a hosts flagged
Fly.io.internal / .flycast hosts blocked (WireGuard-only); public-IP or fly proxy + --from local guidance
AivenTLS; connection-pool URI and aiven_extras notes
HerokuTLS; credential-rotation warning; blocks continuous sync

Every preset also names where to allow the cell’s egress addresses.

Source kindHow
Provider or any reachable serverzb migrate --from <connection string> --to <instance>
Local serverzb migrate --from local --engine postgres --db myapp --to <instance> (runs pg_dump locally)
Filezb migrate --from ./app.sqlite --engine libsql --create (.sql, .dump, .sql.gz, .rdb, .sqlite, .db, .duckdb, .bson.tar, .parquet, .csv)
Another Databasezy instancezb migrate --from inst_abc --to inst_def
APIPOST /v1/orgs/{org}/instances/{id}/migrations (REST API)