# Planetscale

> Use when operating PlanetScale (Vitess serverless MySQL) — branches and the `pscale` CLI, schema changes through deploy requests (Online DDL cutover, ~30-min revert window), tables without foreign keys, or the edge HTTP driver. NOT MySQL engine work like EXPLAIN or index choice (that is `mysql`), NOT generic migration theory (that is `db-migrations`).

- Skill: `ericrisco/planetscale` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add ericrisco/planetscale`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/planetscale/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/planetscale

---


# PlanetScale — Vitess serverless MySQL: branches, deploy requests, no FKs

PlanetScale is serverless, MySQL-compatible database-as-a-service built on **Vitess** — the same
horizontal-scaling layer that runs YouTube. You do not `ALTER TABLE` against production. Schema
changes live on an isolated **branch** (a full copy of the schema), reach production through a
**deploy request** (a pull request for your schema), and apply via **Online DDL**: Vitess builds a
shadow table holding the new schema, replicates rows and in-flight writes old→new with VReplication,
then **cuts over** with no table lock. Because Online DDL and sharding are the platform's center of
gravity, you **design without foreign keys** (FKs are now an opt-in but unsharded-only feature). This
skill owns that workflow — not MySQL the engine.

Heuristic for the boundary: **would this answer be identical on RDS MySQL? Then it is not this skill.**

| When the question is really about | Route to |
| --- | --- |
| A slow query, an `EXPLAIN` plan, an index decision, JSON columns, locking | `../mysql/SKILL.md` for MySQL-engine work; `../postgresdb/SKILL.md` when you want the transferable engine reasoning |
| PostgreSQL engine/design — even PlanetScale's own managed Postgres SQL semantics | `../postgresdb/SKILL.md`. PlanetScale sells Postgres too; this skill is strictly the **Vitess/MySQL branch+deploy-request** flow |
| Engine-agnostic migration *theory* (expand-contract, backfill ordering) | `../db-migrations/SKILL.md`. Here you get the concrete PlanetScale realization, not the theory |
| Postgres copy-on-write branches | `../neon/SKILL.md`. Postgres BaaS → `../supabase/SKILL.md` |
| ORM client ergonomics (Prisma `updateMany`, Drizzle query builder) | `../prisma-orm/SKILL.md` / `../drizzle-orm/SKILL.md`. This skill owns only the PlanetScale-specific knobs those ORMs expose (`relationMode`, serverless driver adapter) |

## Mental model

| PlanetScale concept | Git analogue | What it actually is |
| --- | --- | --- |
| Branch | branch | A full, isolated copy of the schema. `main` is your production branch. |
| Deploy request | pull request | The gate that carries schema from a dev branch to production. |
| Schema diff | the PR diff | The exact DDL the deploy will run, computed by PlanetScale. |
| Online DDL | (no git analogue) | Shadow table + VReplication + non-blocking cutover. No `ALTER` lock. |
| Schema revert | revert commit | A **~30-minute** window after deploy to undo while preserving post-deploy writes. |

The revert window is the one thing people get wrong: it is **not** forever, and it has carve-outs
(dropped tables/columns and FK-constraint changes are not cleanly revertible). After ~30 minutes the
deploy is permanent. Plan rollbacks as new forward deploys, not as "I'll just revert later."

## The deploy-request workflow

Production branches should have **safe migrations** enabled — that is what forces every schema change
through a deploy request instead of letting a raw `ALTER` hit `main`. Production branches are protected
by default. The full lifecycle (numbered steps, real `pscale` commands):

```bash
# 0. Install + authenticate (once)
brew install planetscale/tap/pscale   # or scoop / apt — see pscale docs
pscale auth login

# 1. Create the database (region close to your app)
pscale database create my_app --region us-east

# 2. Create a dev branch off production to hold the change
pscale branch create my_app add-orders-table

# 3. Apply your DDL on the BRANCH, never on main.
#    Open a MySQL shell scoped to the branch and run your CREATE/ALTER:
pscale shell my_app add-orders-table
#    mysql> CREATE TABLE orders (id BIGINT PRIMARY KEY AUTO_INCREMENT, ...);
#    mysql> ALTER TABLE users ADD COLUMN last_seen_at TIMESTAMP NULL;

# 4. Open a deploy request from the branch back to production.
#    --disable-auto-apply gates the cutover behind a manual apply (recommended for risky drops).
pscale deploy-request create my_app add-orders-table --disable-auto-apply

# 5. Inspect the exact schema diff the deploy will run. ALWAYS read this.
pscale deploy-request diff my_app 42

# 6. Review / approve (the schema-PR review step)
pscale deploy-request review my_app 42 --approve

# 7. Deploy. PlanetScale runs Online DDL: shadow table -> VReplication -> cutover.
pscale deploy-request deploy my_app 42

# 8. If you disabled auto-apply, the change waits gated until you apply the cutover:
pscale deploy-request apply my_app 42

# 9. Verify on production. If wrong, REVERT within the ~30-minute window:
pscale deploy-request revert my_app 42
```

**Auto-apply is ON by default.** With it on, step 7 deploys *and* cuts over in one motion. For
anything destructive — dropping a column/table, narrowing a type — pass `--disable-auto-apply` at
step 4 so the cutover sits gated (step 8) until a human applies it. That gap is your last cheap exit
before the revert window starts ticking.

For queued/gated deploys that won't move, lint errors that block a deploy, declarative vs imperative
schema, and the exact revert carve-outs, see [deploy-requests](references/deploy-requests.md).

## Design without foreign keys

Vitess's first-class features are Online DDL and horizontal sharding. Native foreign keys fight both:
a cross-shard FK is meaningless once a table is sharded, and FK constraint checks degrade
high-concurrency writes. PlanetScale **historically disallowed FKs entirely**; they are now an opt-in
but **unsharded-only** feature. The scale-safe default is **app-level referential integrity** with
emulated relations, not database-enforced FKs.

```sql
-- Bad on PlanetScale at scale: a DB-enforced FK with ON DELETE CASCADE.
-- Blocks sharding, conflicts with Online DDL, and the cascade is a hidden write amplifier.
CREATE TABLE orders (
  id        BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id   BIGINT NOT NULL,
  CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- Good: no DB FK. Index the relation column, enforce integrity in the app/ORM,
-- and do cascades explicitly in a transaction or background job.
CREATE TABLE orders (
  id        BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id   BIGINT NOT NULL,
  KEY idx_orders_user_id (user_id)   -- the relation still needs its index
);
```

With Prisma, declare `relationMode = "prisma"` so the client emulates relations and adds the implicit
indexes instead of emitting DB-level FKs:

```prisma
datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}
```

With Drizzle, model the relation in app code (`relations()`), index the join column, and skip the
`references()`-backed FK constraint at the DB layer.

| Will this table shard? | FK strategy | Notes |
| --- | --- | --- |
| Yes, or unsure | No DB FK — emulate in app/ORM | Default. Index the relation column; cascade in code. |
| Never (small, bounded, unsharded) | Unsharded FK opt-in *is* acceptable | Accept Online DDL friction; document why it won't shard. |

See [no-foreign-keys](references/no-foreign-keys.md) for emulated cascades, ORM specifics, and the opt-in caveats.

## Connecting

Two connection paths. Pick by **runtime**, not by preference.

```ts
// Direct MySQL string — for Node/long-lived servers that can open a TCP socket.
// DATABASE_URL=mysql://user:pass@host/db?ssl-mode=require   (TLS required)

// Serverless HTTP driver — for edge/serverless where raw TCP MySQL is blocked.
import { connect } from "@planetscale/database";

const conn = connect({ url: process.env.DATABASE_URL });
const result = await conn.execute("SELECT id FROM users WHERE email = ?", [email]);
```

`@planetscale/database` is Fetch-API compatible: it speaks HTTP, so it works where you cannot open a
MySQL socket — Cloudflare Workers, Vercel Edge, Lambda. The spelling is exactly `@planetscale/database`;
there is no other official package name. Drizzle ships a matching adapter,
`drizzle-orm/planetscale-serverless`, that wraps this driver — see `../drizzle-orm/SKILL.md` for the
query-builder side.

| Runtime | Connection path | Why |
| --- | --- | --- |
| Node server, long-lived process | Direct `mysql://…?ssl-mode=require` | TCP socket available; pool it. |
| Vercel Edge / Cloudflare Workers | `@planetscale/database` (HTTP) | No raw TCP; the Fetch-based driver works. |
| AWS Lambda / short-lived serverless | `@planetscale/database` (HTTP) | Avoids per-invocation connection churn. |
| Drizzle on edge | `drizzle-orm/planetscale-serverless` | Wraps the HTTP driver; same constraints. |

## CI/CD

Wire the deploy-request flow into CI so schema changes are reviewed like code. A GitHub Actions job
installs `pscale`, authenticates with a service token (`PLANETSCALE_SERVICE_TOKEN` /
`PLANETSCALE_SERVICE_TOKEN_ID` as secrets), then drives the same verbs you run locally:

```yaml
# .github/workflows/schema.yml — open + diff a deploy request on PR
- run: |
    pscale deploy-request create "$DB" "${{ github.head_ref }}" --disable-auto-apply
    pscale deploy-request diff   "$DB" "$DR_NUMBER"   # surface the schema diff in the PR
```

For a **branch-per-PR preview database**, create a PlanetScale branch named after the PR head, run the
app's migrations against it, and tear the branch down when the PR closes. The orchestration (matrix,
secrets, cleanup-on-close) is generic CI — defer the Actions plumbing to `../github-actions/SKILL.md`
and keep the PlanetScale verbs here.

## Anti-patterns

| Anti-pattern | Why it bites | Do instead |
| --- | --- | --- |
| `ALTER TABLE` on the `main` production branch | Skips the gate; no diff, no review, no revert window | Branch → DDL → deploy request → diff → deploy |
| Relying on DB-enforced foreign keys at scale | Blocks sharding, fights Online DDL, degrades high-concurrency writes | App/ORM-level integrity; `relationMode="prisma"`; index the relation column |
| Leaving auto-apply ON for a destructive change | Deploy + cutover happen in one motion; no gated last-look before the drop | `--disable-auto-apply`, inspect, then `deploy-request apply` |
| Opening a direct `mysql://` connection from Vercel Edge / Workers | Raw TCP MySQL is blocked on edge runtimes → connection timeouts | Use `@planetscale/database` HTTP driver |
| Treating revert as available forever | The window is **~30 min** and excludes dropped tables/cols and FK changes | Plan rollbacks as new forward deploys |
| Deploying without reading `deploy-request diff` | You ship DDL you never saw; surprise table rewrites | Always run the diff before `deploy` |
| Inventing a driver name (`@planetscale/serverless`, `planetscale-js`) | There is no such package; install fails | The package is exactly `@planetscale/database` |

