sluice

Move a PlanetScale database between regions

PlanetScale has no native region move — create the database in the new region and let sluice copy it across, with zero downtime or in one shot.

A PlanetScale database is pinned to its region at creation, and there is no native, in-place region move. The path is the same in spirit for every setup — create a new database in the target region and let sluice copy the data across (both ends are MySQL→MySQL, so no cross-engine type translation is involved) — but the exact shape depends on how your data is laid out. This guide covers three cases: Case 1 — a single unsharded database (the common one), Case 2 — several unsharded databases, and Case 3 — a sharded keyspace. Read Before you start and Provision the target first — they apply to all three — then jump to the case that matches your setup.

Before you start & gotchas #

Provision the target & connect #

Create the destination database in the new region, sized to match (or exceed) the source. A PS-10 branch takes roughly 7–8 minutes to reach READY:

pscale database create app-sa --region aws-sa-east-1 --cluster-size PS-10

Both the source and the target reach PlanetScale through the same global connect host, aws.connect.psdb.cloud:3306 — PlanetScale routes to the right region by credential, not by hostname. The connection strings are standard go-sql-driver MySQL DSNs, and ?tls=true is required on both:

# source (CDC read) — export as SLUICE_SOURCE
USERNAME:PASSWORD@tcp(aws.connect.psdb.cloud:3306)/app-us?tls=true

# target (write) — export as SLUICE_TARGET
USERNAME:PASSWORD@tcp(aws.connect.psdb.cloud:3306)/app-sa?tls=true

USERNAME is the generated username field returned by pscale password create <db> <branch> <label> — not the label you pass on the command line — and PASSWORD is its plain_text value. Prefer environment variables (SLUICE_SOURCE / SLUICE_TARGET) over putting the DSN in argv, so credentials don't land in your shell history or process list.

The target driver is planetscale, not mysql. The mysql engine cold-copies with LOAD DATA INFILE, which Vitess/PlanetScale blocks; the planetscale engine uses batched inserts and speaks VStream for CDC. Use --source-driver planetscale and --target-driver planetscale on both ends.
The target password needs --role admin. sluice creates the data tables plus (for a sync) small control tables, and lesser roles (reader/writer/readwriter) are denied DDL on a production branch. Mint it with pscale password create app-sa main mover --role admin. The source password only needs read access.
No special flag for a normal database. On v0.99.190+, an ordinary unsharded PlanetScale database — the default, one keyspace per database — syncs and migrates with no extra flags. --allow-cross-shard-merge is only for a genuinely sharded source keyspace, where it opts out of the guard that stops rows from different shards colliding on a shared key (prefer --inject-shard-column when the key isn't globally unique across shards). On sluice ≤ v0.99.189 an unsharded database needed --allow-cross-shard-merge as a workaround for a shard-detection bug fixed in v0.99.190 — upgrade and drop it.

Case 1 — a single unsharded database #

The overwhelming majority of PlanetScale databases are a single unsharded keyspace — this is the straightforward case. Pick a zero-downtime continuous sync + cutover (recommended) or a one-shot migrate; both are MySQL→MySQL. Datasets that need this are typically well under 10 GB.

Option A — zero-downtime (recommended) #

A continuous sync snapshots and bulk-copies the source, then streams live CDC — so the source stays writable the whole time and you flip traffic in a brief, controlled window. Start with a dry-run to review the plan, then launch the long-lived stream:

# review the plan first
sluice sync start --stream-id region-move \
    --source-driver planetscale --source "$SLUICE_SOURCE" \
    --target-driver planetscale --target "$SLUICE_TARGET" \
    --dry-run --format json

# launch the long-lived stream (snapshot -> bulk copy -> live CDC)
sluice sync start --stream-id region-move \
    --source-driver planetscale --source "$SLUICE_SOURCE" \
    --target-driver planetscale --target "$SLUICE_TARGET" \
    --apply-batch-size 50

If any table is large — hundreds of GB, or one you have seen a slow index build on — add --upfront-indexes to that second command. It builds the secondary indexes before the copy instead of after, so a post-copy ADD INDEX cannot run into PlanetScale's statement-time limit once the rows are already in place. See the gotcha above for the trade-off.

Watch it catch up from another shell, and gate cutover on freshness:

sluice sync status --stream-id region-move \
    --target-driver planetscale --target "$SLUICE_TARGET"

sluice sync health --stream-id region-move \
    --target-driver planetscale --target "$SLUICE_TARGET" --max-stale-seconds 30

At ~1.2 GB the cold-start-to-tailing transition took about 5 minutes in testing (bulk copy ~4 MB/s, PS-10 CPU-bound — a larger cluster tier is the throughput lever). Once the stream is tailing, cut over: cutover primes the target's AUTO_INCREMENT past the source's, with a safety margin, so the application can start writing to the target without primary-key collisions. Then stop the stream and verify:

sluice cutover \
    --source-driver planetscale --source "$SLUICE_SOURCE" \
    --target-driver planetscale --target "$SLUICE_TARGET"

sluice sync stop --stream-id region-move \
    --target-driver planetscale --target "$SLUICE_TARGET" --wait

sluice verify \
    --source-driver planetscale --source "$SLUICE_SOURCE" \
    --target-driver planetscale --target "$SLUICE_TARGET"
Wait for caught-up before cutover. A trickle of changes can take tens of seconds to ~2 minutes to appear on the target — that latency is PlanetScale VStream's roughly 60 s server-side delivery cadence, not sluice (the applier commits within seconds of receiving an event). Under sustained write load, lag stays low. So before you cut over, wait for sync health / verify to report caught-up rather than trusting a fixed timer.
Keep --apply-batch-size in the 25–50 range on a PS target. Above 50, a batch's apply transaction can trip Vitess's 20-second transaction killer. 50 is a safe default here. The field note The 20-second guillotine over a WAN explains why — and why insert-heavy syncs now pipeline past the limit.

Option B — one-shot migrate #

If you can take a short maintenance window, a one-shot migrate is simpler — one command, no control tables left behind, and identity_sync auto-primes AUTO_INCREMENT so there's no separate cutover step:

sluice migrate \
    --source-driver planetscale --source "$SLUICE_SOURCE" \
    --target-driver planetscale --target "$SLUICE_TARGET" --dry-run

sluice migrate \
    --source-driver planetscale --source "$SLUICE_SOURCE" \
    --target-driver planetscale --target "$SLUICE_TARGET"

sluice verify \
    --source-driver planetscale --source "$SLUICE_SOURCE" \
    --target-driver planetscale --target "$SLUICE_TARGET"

The catch: a migrate is a point-in-time copy with no CDC, so any row written to the source after it starts is missed. That means you must freeze writes to the source for the entire copy window — about 14 minutes for the 1.2 GB test dataset. It's a good fit for a small database you can quiesce briefly.

Which to choose #

Zero-downtime (Option A) is the better default for a real region move: no write freeze, and in testing it was also about 3× faster on the bulk copy than one-shot (~5 minutes vs ~14 minutes for the same 1.2 GB). One-shot (Option B) wins only on simplicity, when a brief maintenance window is acceptable and you'd rather not run a long-lived stream or a separate cutover.

Case 2 — several unsharded databases #

A PlanetScale database is one keyspace, and each sync start / migrate moves exactly one source keyspace to one target — so moving several databases means one run per database, each with its own --stream-id and its own target. There is nothing exotic here: it is Case 1 repeated per keyspace, run in parallel or supervised together.

Per database: create the target database in the new region, mint its --role admin password, and run the same Case-1 flow (Option A or Option B) with a distinct --stream-id. Moving two databases (app-us-1→app-sa-1 and app-us-2→app-sa-2) is just two independent invocations, each pointed at its own source and target:

# database 1
sluice sync start --stream-id app-1-region-move \
    --source-driver planetscale --source "$SLUICE_SOURCE_1" \
    --target-driver planetscale --target "$SLUICE_TARGET_1" \
    --apply-batch-size 50

# database 2 — independent stream, independent target
sluice sync start --stream-id app-2-region-move \
    --source-driver planetscale --source "$SLUICE_SOURCE_2" \
    --target-driver planetscale --target "$SLUICE_TARGET_2" \
    --apply-batch-size 50
Supervise many at once with a fleet config. Running each sync as its own process gets unwieldy past a handful. A sync fleet config collapses them into one supervised, failure-isolated process — one entry per database, each with its own stream-id and target — so a whole fleet of region moves runs and reloads from a single place.

Case 3 — a sharded keyspace #

A genuinely sharded PlanetScale keyspace — multiple shards behind a vindex — is supported as a source on sluice v0.99.191+; earlier versions failed the cold copy with 0 rows. Because vtgate merges all shards into one logical stream, sluice's cross-shard-collision guard requires you to opt in: pass --allow-cross-shard-merge when your key is globally unique across shards (a hash vindex puts each id on exactly one shard, so ids are disjoint and the merge is safe), or --inject-shard-column to add a shard-discriminator column instead.

Multi-keyspace sources are shard-complete (v0.99.196+). If your database holds several keyspaces, sluice cross-checks shard discovery against SHOW VITESS_TABLETS: vtgate's SHOW VITESS_SHARDS can silently omit a fully-serving secondary sharded keyspace, so trusting it alone could miss an entire keyspace's shards. sluice unions the two sources and warns on any discrepancy, so a sync or backup never silently skips a shard. A single-keyspace source was never affected (migrate isn't either — its bulk copy is a plain scatter query vtgate fans out across every serving shard).

Sub-case A — merge into an unsharded target #

The simplest path, and it works out of the box: point the sharded source at an unsharded target database with --allow-cross-shard-merge, and every shard's rows land in the one target table. Live-proven — all rows copied, no FailedPrecondition. Note the source DSN's database is the keyspace name (here sks):

sluice migrate \
    --source-driver planetscale \
    --source "USER:PASS@tcp(aws.connect.psdb.cloud:3306)/sks?tls=true" \
    --target-driver planetscale --target "$SLUICE_TARGET" \
    --allow-cross-shard-merge

Sub-case B — preserve sharding (sharded → sharded) #

To keep the data sharded, create a target keyspace sharded with the same vschema / vindex. sluice's cold copy and live CDC then route every row to the correct target shard — validated: INSERT / UPDATE / DELETE across both shards land on the matching shard, with under 20 seconds of CDC lag. sluice will not create tables in a sharded keyspace (the tables it creates carry no vindex, SLUICE-E-SCHEMA-TARGET-KEYSPACE-SHARDED), so pre-create every table with its vindex on the platform first. That only gets you past the create step.

What the apply supports on a pre-vindexed sharded keyspace: every vindex column must be in the primary key (v0.156.9, GC-41 (e)). vtgate refuses a write that assigns a vindex column. Measured on a 2-shard vttestserver:
  • Every vindex column inside the primary key (the primary vindex on the primary key, or on a member of a composite one): inserts, updates and deletes apply on every apply path — batched, the concurrent lanes, and the per-change path (--apply-batch-size 1, the broker and chain restore, a partial after-image including the default-on ADD COLUMN backfill, a primary-key change). Since v0.156.9 the per-change UPDATE leaves a key column whose value did not change out of its SET list, so it no longer re-states the vindex column; a primary-key change that keeps the vindex value (on (tenant, id) vindexed on tenant, a new id) is an in-shard update and applies. Through v0.156.8 every such per-change update stopped the stream.
  • A vindex column outside the primary key (a primary vindex on a non-key column, a secondary/lookup vindex on one, or a table with no primary key): not supported. vtgate refuses an ON DUPLICATE KEY UPDATE that assigns a vindex column, and the spellings it accepts duplicate a moved row across shards, so there is no write sluice can send. A sync cold start (and its stopped-copy resume) reads each table's vindexes with SHOW VSCHEMA VINDEXES ON and refuses before anything is written with SLUICE-E-TARGET-SHARD-KEY-NOT-IN-UPSERT-KEY, naming the table and the column. Put every vindex column in the primary key. A warm restart of an existing stream runs no such preflight; there the first write vtgate refuses stops the stream with SHARDED-TARGET-VINDEX-UPDATE. If the vschema cannot be read (a role without the privilege), the preflight WARNs and the same write-time refusal applies.
  • A change that really moves a row's vindex value (a change to a vindexed primary-key column, or a new value in a vindex column) cannot be applied through vtgate at all.
  • Two narrow shapes stay refused even with the vindex in the key, because the only thing left to assign is the vindexed key column: an insert into a table whose every column is in the primary key when the first key column is a vindex column, and an UPDATE whose after-image carries nothing but unchanged key columns. Both are filed as the remainder of GC-41 (e).
Each write-time refusal is loud and terminal, with the position left where it was: SHARDED-TARGET-VINDEX-UPDATE, no error code, exit 1. Under sluice sync run the leg is marked failed and not restarted.
Control tables live in a sidecar keyspace — handled automatically on v0.99.193+. A sharded target can't hold sluice's internal control tables (sluice_cdc_state, sluice_cdc_schema_history, sluice_shard_consolidation_lease, sluice_cdc_skipped_tables, sluice_cdc_query_timeout_raise, sluice_cdc_apply_marks, sluice_cdc_column_order) directly — Vitess requires every table in a sharded keyspace to carry a vindex. On v0.99.193+ sluice handles this: it auto-detects the database's default unsharded keyspace and creates its control tables there, so a sharded→sharded sync needs no manual control-table setup (the apply scope above still applies). Pass --control-keyspace <name> only to override (and if the database has several unsharded keyspaces, sluice asks you to choose one). What a sidecar costs: the commit can tear. The position write and the apply marks still ride the same target transaction as the rows, but that transaction now spans keyspaces, and under vtgate's default transaction_mode=MULTI it is committed shard by shard with no two-phase commit. sluice sends every row before any control-table write (since v0.156.7), so the data commits first. A tear therefore leaves the position and marks behind the rows, never past rows that did not land, and the restart re-delivers the change. For the classes apply marks cover, that makes them at-least-once or loud here rather than exactly-once: a secondary-unique or primary-key-changing change stops on a unique collision, and a keyless table can gain a duplicate row. Nothing is skipped silently. The ordering covers the control keyspace only: a transaction whose rows span two data shards is not made atomic, and a tear between data shards is not covered. (On v0.156.5 and v0.156.6 the serial batch path could send the control writes first, so a torn commit could move the position past rows that never landed; upgrade.) On sluice before v0.99.193 this aborted with VT09001: … does not have a primary vindex before any rows copied; upgrade to v0.99.193+. (The merge sub-case A and a one-shot full backup/restore below never need a sidecar; a chain restore into a sharded target does — see below.)

Backup / restore a sharded source #

If you just want a point-in-time copy of a sharded keyspace into a plain database, backup full reads a sharded source with no special flag — it flattens the shards into one logical stream — and restore into a PlanetScale MySQL database needs no --allow-cross-shard-merge, because the backup already flattened it:

sluice backup full \
    --source-driver planetscale \
    --source "USER:PASS@tcp(aws.connect.psdb.cloud:3306)/sks?tls=true" \
    --output-dir ./sks-backup

sluice restore \
    --from-dir ./sks-backup \
    --target-driver planetscale --target "$SLUICE_TARGET"

Restoring back into a sharded keyspace. Flattening on the backup side doesn't lock you into an unsharded target. Restore into a keyspace that's sharded with the same vindex and Vitess re-routes every row to its correct shard by the vindex hash — the same routing the live sync in sub-case B uses — so per-shard placement on the target matches the source exactly. Create the target keyspace sharded with a matching vschema first, then point restore at it (the --target database is the keyspace name):

sluice restore \
    --from-dir ./sks-backup \
    --target-driver planetscale \
    --target "USER:PASS@tcp(aws.connect.psdb.cloud:3306)/sks?tls=true"
A full restore just works; a chain restore needs the same sidecar as sync (v0.99.195+). A single full restore into a sharded target needs no extra flags — it writes only your data tables, which Vitess shards by their vindex, and creates no control tables; per-shard placement matches the source. A chain restore (a full plus one or more incrementals) is different: replaying the incrementals writes sluice's CDC control tables (sluice_cdc_state and friends), which a sharded keyspace can't hold — so restore --control-keyspace <name> routes them to an unsharded sidecar keyspace, auto-detected on v0.99.195+ exactly like the sync case above (pass the flag only to override, or if the database has several unsharded keyspaces). The incremental replay applies changes on the per-change path, so on a sharded target an UPDATE in an incremental is refused with SHARDED-TARGET-VINDEX-UPDATE (see the apply scope above). Before v0.99.195 a chain restore into a sharded target aborted with … does not have a primary vindex; a single full restore was always fine.

Next steps #