PlanetScale Branching: Ship Schema Changes Without Downtime

by goyostudio

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.

# Install and authenticate
brew install planetscale/tap/pscale
pscale auth login
pscale auth check

pscale 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.

# Point the CLI at the right organization
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.

# Create a database in the right region
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.

# Turn main into a protected production branch
pscale branch promote mydb main
pscale branch safe-migrations enable mydb main

Safe 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.

# Create branches
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.

# Query a branch, or tunnel it to localhost
pscale shell mydb add-users-table
pscale connect mydb add-users-table --port 3309

Neither 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.

# Pre-flight the change
pscale branch schema mydb add-users-table
pscale branch diff mydb add-users-table
pscale branch lint mydb add-users-table

branch 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.

# Open, inspect, approve
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.

# Deploy and watch
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.

# Instant and gated deploys
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.

# Roll back, or end the window early
pscale deploy-request revert mydb 12
pscale deploy-request skip-revert mydb 12

The 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.

# Clean up
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.

# Run the dev server against a branch
pscale connect mydb dev-branch --execute 'pnpm dev'
pscale connect mydb dev-branch --execute 'npm start' --execute-protocol mysql

The 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.

# Pin the repo to a branch
pscale branch switch add-users-table --database mydb
pscale branch switch new-feature --database mydb --create

Deployed 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.

# Mint credentials for real environments
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.

# Ephemeral branches in a pipeline
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-org

Every 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 --wait forks the current production schema into an isolated copy with empty tables.
  • Connect: pscale shell mydb add-users-table for queries, or pscale connect mydb add-users-table to point your app at it. Direct DDL is fine here.
  • Self-review: pscale branch diff for what changed, pscale branch lint for 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, then pscale deploy-request deploy mydb 12 --wait — an online migration with no locks and no downtime.
  • Close out: revert inside the window if it went wrong, skip-revert if it went right, then delete the branch (or let --auto-delete-branch do 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.

// related cheat sheets