PlanetScale Branching: Ship Schema Changes Without Downtime
Schema changes are the riskiest routine change most teams ship. Application code has version control, review, CI, and a rollback that is one revert commit away. A production ALTER TABLE has none of that: it runs once, it can lock the table while it runs, and undoing it means writing the inverse migration by hand while the site is down.
PlanetScale's answer is to give the database the primitives git gave your code. Branches are isolated copies of your schema; deploy requests are pull requests for those branches — diff, linter, approval, revert window. The pscale CLI drives the whole loop from a terminal.
## Setup: CLI, auth, org
The CLI is a single binary that talks to the PlanetScale API. On macOS it comes from PlanetScale's Homebrew tap; Linux and Windows get binaries, apt/yum packages, or scoop.
brew install planetscale/tap/pscale
pscale auth login
pscale auth checkpscale auth login runs a browser flow and caches the session under ~/.config/planetscale/pscale.yml, which also records your default organization. If you belong to more than one org, set it once — almost every baffling "database not found" error is an org mismatch, not a missing database.
pscale org list
pscale org show
pscale org switch my-org--org overrides the default for a single command, but placement matters: pscale database list --org other-org works, pscale --org other-org database list does not.
Starting from scratch, create the database and its main branch in the region closest to your application servers — not to your laptop. Region is fixed at creation, so moving later means dump-and-restore into a new database. pscale ping measures real round-trip time to every public edge, ideally run from a CI runner or app server rather than your desk.
pscale region list
pscale ping
pscale database create mydb --region us-east --wait## Production branches vs development branches
Every database starts with a default branch, usually main. A production branch is highly available, backed up daily, and — once safe migrations are on — protected from direct DDL. Development branches are cheap, disposable copies you delete after the change ships.
pscale branch promote mydb main
pscale branch safe-migrations enable mydb mainSafe migrations is what makes the rest of the workflow non-optional. With it enabled, ALTER TABLE, CREATE TABLE, and DROP TABLE issued directly against main are rejected, so every schema change has to arrive through a deploy request — which is what buys you linting, review, an online rollout, and a revert window. Promotion alone is not enough; enable safe migrations too, or deploy requests cannot deploy into the branch.
// note: Safe migrations breaks ORM auto-migration against production by design. drizzle-kit push, prisma db push, and Rails migrations pointed at a production branch will fail once it is on. That is the feature: run those tools against a development branch and let a deploy request carry the change to production.
## Branch, then change the schema there
One branch per feature or per developer is the normal rhythm. A new branch copies the parent's schema and starts with empty tables — no data comes along unless you ask for it.
pscale branch create mydb add-users-table --wait
pscale branch create mydb add-users-index --from add-users-table
pscale branch create mydb test-migration --seed-data--from forks from a branch other than the default, which is how you stack a follow-up change on top of one that has not deployed yet. Deploy stacked branches from the bottom up: each deploy request diffs against its own parent, so deploying out of order produces diffs you did not intend.
--seed-data uses Data Branching to clone the parent's actual rows into the branch — how you rehearse a risky migration or a backfill against realistically shaped data. The trade-off is cost and compliance: a seeded branch is provisioned with the same resources as its parent, not the small development default, and it puts production data into a development-grade branch. Delete it when the experiment is done.
// note: pscale branch create returns as soon as the API accepts the request while provisioning continues in the background. In CI, a fast follow-up command — shell, connect, password create — races that provisioning and fails intermittently. --wait blocks until the branch is genuinely ready and eliminates the classic works-on-retry flake.
### Connect to the branch
Two ways in. pscale shell opens an interactive SQL prompt through a secure proxy — mysql for Vitess databases, psql for Postgres — with no credentials to manage. pscale connect opens a persistent local tunnel and exposes the branch as a plain MySQL server on 127.0.0.1:3306, so any client, GUI, or app connects as if the database were local.
pscale shell mydb add-users-table
pscale connect mydb add-users-table --port 3309Neither needs a password in an env file. Two rough edges: shell shells out to your local mysql or psql binary, which the CLI does not bundle — brew install mysql-client is part of setup. And if 3306 is taken, connect quietly picks a random port, so pin one with --port.
Then apply the change however you normally would: raw DDL in the shell, or your ORM's migration command. It is a development branch — direct DDL is exactly what it is for.
Before opening a deploy request, read your own diff and run the linter — both surface problems you would rather find in your terminal than in a review comment.
pscale branch schema mydb add-users-table
pscale branch diff mydb add-users-table
pscale branch lint mydb add-users-tablebranch diff shows exactly what the branch introduces relative to its parent — the same diff the deploy request will show. It is schema-only: rows you inserted while testing do not appear and will not deploy. Deploy requests move schema, never data.
## Deploy requests: pull requests for your schema
Open one and the mental model maps one-to-one onto a GitHub PR: a numbered request, a diff, an automatic lint pass, comments, an approval, and a merge. The number is the handle every other deploy-request command takes.
pscale deploy-request create mydb add-users-table --notes 'adds users table for auth'
pscale deploy-request list mydb
pscale deploy-request diff mydb 12
pscale deploy-request review mydb 12 --approve--into staging targets a base branch other than the branch's parent, for setups where changes land in staging first. Read the diff for destructive operations — DROP COLUMN and DROP TABLE lint as warnings, deploy fine, and delete data. Approving does not deploy; it unlocks deploying.
The deploy itself is where the non-blocking part happens. Vitess applies the change as an online migration: it builds a shadow copy of the table with the new schema, backfills it while replicating live writes, then swaps the two atomically. Millions of rows migrate with no table locks and no downtime.
pscale deploy-request deploy mydb 12 --wait
pscale deploy-request show mydb 12--wait blocks until the swap completes, which is what a pipeline wants. A large table takes real time to backfill and deploys queue behind one another — a long-running deploy is the design working, not a hang.
Two variants are worth knowing. --instant uses MySQL's ALGORITHM=INSTANT for eligible changes such as adding a column, skipping the shadow-table copy so the deploy finishes in seconds regardless of table size. --disable-auto-apply runs the backfill but pauses before the final swap until you run deploy-request apply, letting you deploy during the day and cut over in a quiet window.
pscale deploy-request deploy mydb 12 --instant
pscale deploy-request create mydb risky-change --disable-auto-apply
pscale deploy-request apply mydb 12// note: An --instant deploy cannot be reverted — there is no shadow copy to fall back to. Reserve it for low-risk additive changes and route anything you might want to undo through the normal path. A gated deploy left unapplied also holds resources and blocks the deploy queue, so apply or cancel it the same day.
## Reverting a bad deploy
This is the piece with no equivalent in a hand-run migration. During the revert window, Vitess keeps the old and new schemas in sync — including writes that landed after the deploy — so rolling back is lossless rather than a restore from backup. A bad migration becomes a command instead of an incident.
pscale deploy-request revert mydb 12
pscale deploy-request skip-revert mydb 12The window is finite: it lasts until the deploy request is closed or the retention period passes. If the change is behaving and you want the machinery released early, skip-revert ends the window deliberately. For a deploy still in progress, cancel stops it safely — the shadow-table approach means nothing partial was ever swapped in.
Closing a deploy request does not delete the source branch. Pass --auto-delete-branch at creation, or clean up once the change is live — stale branches linger and, above the free allowance, cost money.
pscale deploy-request close mydb 12
pscale branch delete mydb add-users-table
pscale branch list mydb --format json | jq -r '.[].name'## Running your app against a branch
For local development, pscale connect can start the tunnel, inject the resulting connection string into a child process, run your dev server, and tear the tunnel down when it exits. No secrets in any env file, and the app talks to a real branch.
pscale connect mydb dev-branch --execute 'pnpm dev'
pscale connect mydb dev-branch --execute 'npm start' --execute-protocol mysqlThe injected variable is DATABASE_URL by default; --execute-env-url renames it, and --execute-protocol should match your driver — the default mysql2 is a Rails-ism, while Prisma wants mysql://. Writing a .pscale.yml with pscale branch switch pins the repo to a database and branch so you stop repeating both on every command.
pscale branch switch add-users-table --database mydb
pscale branch switch new-feature --database mydb --createDeployed applications do not use the tunnel — that is a local-dev tool. In production the app authenticates with a branch password: a named, role-scoped credential minted per branch and per consumer. Name it after the consumer so password list reads like an access inventory, and scope it by least privilege — reader for dashboards, readwriter for the app, admin only for migration tooling.
pscale password create mydb main my-app-prod
pscale password create mydb main metabase-ro --role reader --replica
pscale password create mydb main debug-session --ttl 2h// note: The plaintext password is shown exactly once, at creation, and can never be revealed again — password list returns only names, roles, and IDs. Store it in your secret manager immediately. Rotation order is create the new one, deploy it, then pscale password delete the old one; deleting first means downtime while you scramble.
## Driving it from CI
The browser login flow cannot run in CI or over SSH. Create a service token instead, grant it narrowly scoped access per database, and pass it with the --service-token-id and --service-token flags or the PLANETSCALE_SERVICE_TOKEN_ID and PLANETSCALE_SERVICE_TOKEN environment variables. Service tokens are org-scoped, so --org still has to be spelled out — a mismatch fails with a confusing not-found error.
pscale service-token create --org my-org
pscale service-token add-access <token-id> create_branch read_branch --database mydb --org my-org
pscale branch create mydb ci-check-$GITHUB_RUN_ID --wait --org my-orgEvery command accepts --format json, and exit codes are meaningful, so pscale branch show doubles as an existence check. That is enough for CI to spin up a throwaway branch per pull request and delete it when the run finishes.
## One caveat about engines
Deploy requests, pscale connect, and branch passwords are Vitess (MySQL) features. PlanetScale for Postgres uses the same branching model but applies schema changes directly, and uses real Postgres roles (pscale role create) instead of branch passwords. Branches, diffs, and cleanup still apply; the deploy-request layer does not.
## The loop, end to end
- Branch:
pscale branch create mydb add-users-table --waitforks the current production schema into an isolated copy with empty tables. - Connect:
pscale shell mydb add-users-tablefor queries, orpscale connect mydb add-users-tableto point your app at it. Direct DDL is fine here. - Self-review:
pscale branch difffor what changed,pscale branch lintfor what a linter can catch, before a human spends attention on it. - Open the request:
pscale deploy-request create mydb add-users-table --notes '...'puts the schema diff in front of a reviewer. - Deploy:
pscale deploy-request review mydb 12 --approve, thenpscale deploy-request deploy mydb 12 --wait— an online migration with no locks and no downtime. - Close out:
revertinside the window if it went wrong,skip-revertif it went right, then delete the branch (or let--auto-delete-branchdo it).
Nothing here is exotic. It is the review workflow you already run for application code, applied to the one class of change that historically escaped it.