Claude Code with PlanetScale: Schema-Safe MySQL at Scale
Why PlanetScale without CLAUDE.md breaks production schemas
PlanetScale is a serverless MySQL platform built on Vitess. It gives developers Git-style database branching, non-blocking schema changes, and horizontal sharding without the operational burden of running Vitess directly. The development experience is excellent: branch the database, change the schema, open a deploy request, merge to production with zero downtime.
The problem is that Claude Code does not know which parts of MySQL it cannot use on PlanetScale. By default Claude generates schemas with FOREIGN KEY constraints, multi-table JOIN queries that assume single-shard execution, and migrations that bypass the deploy request workflow entirely. The schema compiles. The queries run in development. Then production fails when the workload hits a sharded keyspace or a constraint that Vitess silently dropped.
The Vitess constraints that catch Claude every time: foreign keys are not enforced across shards (and PlanetScale recommends avoiding them entirely), cross-shard transactions have higher latency and lower isolation, ORDER BY without LIMIT on large tables can OOM the routing layer, and any ALTER TABLE issued outside a branch deploy request is rejected on production. On top of those, Claude often hardcodes connection strings that include the password directly rather than using PlanetScale's hashed credentials, which rotates the secret out of source control instantly.
This guide covers the CLAUDE.md configuration that locks Claude Code into PlanetScale's correct model: branch-first development, deploy requests as the only path to production, Vitess-aware schema design, and the connection patterns that match the platform's serverless architecture. If you are building a Next.js application that sits on top of PlanetScale, Claude Code with Next.js covers the request lifecycle. For type-safe queries against PlanetScale, Claude Code with Drizzle shows how to wire Drizzle ORM to a PlanetScale connection string.
The PlanetScale CLAUDE.md template
The CLAUDE.md at your project root needs to declare: the connection driver, branch and deploy-request rules, the Vitess constraints that differ from vanilla MySQL, the connection string format, and the hard rules that prevent the production-breaking patterns Claude generates without them.
# PlanetScale rules
## Stack
- PlanetScale (Vitess-backed MySQL 8.0)
- @planetscale/database ^1.x for serverless HTTP driver
- mysql2 ^3.x as fallback for long-lived connections
- pscale CLI for branch and deploy-request operations
- DATABASE_URL in .env.local (never hardcode)
## Branch workflow (MANDATORY)
ALL schema changes go through this sequence:
1. pscale branch create <db> <branch>
2. pscale shell <db> <branch> (or migration tool against the branch URL)
3. Apply schema changes ONLY to the branch
4. pscale deploy-request create <db> <branch>
5. Human review of the deploy request diff
6. pscale deploy-request deploy <db> <number>
NEVER apply schema changes to main directly.
NEVER skip the deploy request step.
## Vitess constraints (NON-NEGOTIABLE)
- NO FOREIGN KEY constraints in schema (Vitess does not enforce them across shards)
- NO ON DELETE CASCADE / ON UPDATE CASCADE (depends on FK enforcement)
- Cross-shard transactions are SUPPORTED but high-latency, avoid in hot paths
- Every table MUST have a PRIMARY KEY (Vitess requires it for routing)
- LIMIT on every unbounded SELECT (no ORDER BY without LIMIT on large tables)
- Use application-layer referential integrity instead of FK constraints
- Use BIGINT for primary keys on tables expected to grow past 2B rows
## Connection driver selection
- Serverless / edge (Vercel Edge, Cloudflare Workers): @planetscale/database (HTTP)
- Long-lived servers (Node.js with connection pool): mysql2 with PlanetScale URL
- NEVER use the mysql (not mysql2) driver, it lacks the prepared statement support PlanetScale requires
## Hard rules
- NEVER apply migrations to main branch directly
- NEVER add FOREIGN KEY constraints (PlanetScale will reject the deploy request)
- NEVER hardcode DATABASE_URL in source files
- NEVER commit the unhashed PlanetScale connection string (only the hashed form)
- NEVER write SELECT without LIMIT on tables > 100k rows
- ALWAYS check DATABASE_URL is defined at startup, not at query time
Three rules here block the failures Claude generates most often.
The branch workflow rule prevents the most expensive mistake. Without it, Claude generates migration scripts that target the production database directly. PlanetScale rejects these on production, which is the right outcome, but the developer has wasted a debugging cycle. With the rule, Claude generates pscale branch create and pscale deploy-request commands as the natural pattern.
The no foreign keys rule prevents a class of schema mistakes Claude inherits from vanilla MySQL training data. Foreign keys are valid MySQL syntax. PlanetScale's documentation explicitly recommends against them because they do not work across shards and add overhead even on single-shard keyspaces. Telling Claude this once in CLAUDE.md changes every schema it generates.
The LIMIT rule prevents query plans that work in development on small tables and OOM the Vitess router in production. Vitess routes queries through a gateway that buffers results before returning them. An unbounded SELECT against a 50-million-row table will exhaust memory at the gateway before any data reaches the application. Claude does not add LIMIT by default unless instructed.
Install and connection setup
Install the serverless driver (recommended for most use cases):
npm i @planetscale/database
For long-lived Node.js servers where you want connection pooling:
npm i mysql2
Add the connection string to your environment:
# .env.local (hashed connection string from PlanetScale dashboard)
DATABASE_URL=mysql://abc123_hashed_user:pscale_pw_HASHED@aws.connect.psdb.cloud/your-db-name?ssl={"rejectUnauthorized":true}
The hashed form is the only form that should ever exist in your repository or environment files. PlanetScale generates these per-application from the dashboard. They look like a normal connection string but the username and password are HMAC-derived tokens that PlanetScale can rotate without breaking your application's record of which credential was used. If you ever paste a non-hashed password into source control, rotate it immediately from the dashboard.
Create a singleton client. For the serverless HTTP driver:
// src/lib/db.ts
import { connect } from '@planetscale/database';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is not defined');
}
export const db = connect({
url: process.env.DATABASE_URL,
});
For the mysql2 pooled connection:
// src/lib/db.ts
import mysql from 'mysql2/promise';
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is not defined');
}
export const db = mysql.createPool({
uri: process.env.DATABASE_URL,
connectionLimit: 10,
waitForConnections: true,
queueLimit: 0,
});
Add the singleton rule to CLAUDE.md so Claude never instantiates a new client inline:
## Client singleton (ENFORCE)
- The only PlanetScale client lives at src/lib/db.ts
- Every query imports: import { db } from '@/lib/db'
- Claude MUST NOT call connect() or mysql.createPool() inline in route handlers
The branch workflow in practice
The PlanetScale branch model maps closely to Git. main is your production branch. Development happens on feature branches that you create, modify, and merge via deploy requests.
Create a feature branch from main:
pscale branch create your-db add-user-preferences
Connect to the branch shell and apply the schema change:
pscale shell your-db add-user-preferences
CREATE TABLE user_preferences (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
theme VARCHAR(32) NOT NULL DEFAULT 'system',
notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id)
);
Notice what is missing: no FOREIGN KEY (user_id) REFERENCES users(id). The relationship is enforced at the application layer. The INDEX idx_user_id (user_id) ensures that lookups by user remain fast.
Open a deploy request:
pscale deploy-request create your-db add-user-preferences
PlanetScale runs a schema diff and surfaces any conflicts with main. A reviewer (you or another developer) inspects the diff in the dashboard. When approved:
pscale deploy-request deploy your-db 42
The deploy executes the schema change on production without blocking writes. Vitess applies it using an online schema change algorithm (gh-ost-style under the hood) that copies data to a shadow table and atomically swaps.
Add the workflow as a CLAUDE.md command sequence Claude can reference:
## Branch workflow commands
Create branch:
pscale branch create $DB_NAME $BRANCH_NAME
Connect to branch:
pscale shell $DB_NAME $BRANCH_NAME
Run migrations against branch (Drizzle example):
DATABASE_URL=$(pscale connect $DB_NAME $BRANCH_NAME --execute "echo $DATABASE_URL") \
npx drizzle-kit push:mysql
Create deploy request:
pscale deploy-request create $DB_NAME $BRANCH_NAME
Deploy:
pscale deploy-request deploy $DB_NAME $DEPLOY_REQUEST_NUMBER
NEVER pipe a migration directly to production. The deploy request IS the migration.
Schema design for Vitess
Vitess shards data by keyspace. Each shard holds a subset of rows determined by a vindex (a hash on the primary key by default). Cross-shard operations work but cost more in latency and resources. Schema design for Vitess optimises for keeping operations within a single shard.
The four schema rules that matter:
| Rule | Why |
|---|---|
| Every table has a PRIMARY KEY | Vitess uses the PK as the default vindex for routing |
| No FOREIGN KEY constraints | Vitess does not enforce FK across shards, and overhead on single shards |
| Use BIGINT UNSIGNED for high-growth PKs | INT signed maxes at 2.1B, easy to hit on event tables |
| Index every column used in WHERE | Vitess gateway cannot do cross-shard secondary index lookups efficiently |
A practical example. A naive vanilla MySQL schema for an e-commerce orders table:
-- DO NOT USE on PlanetScale
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
customer_id INT NOT NULL,
total_cents INT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE
);
Three problems: INT will overflow on a high-volume store, FOREIGN KEY is not enforced, and ON DELETE CASCADE depends on FK enforcement that does not exist. The PlanetScale-correct version:
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL,
total_cents BIGINT UNSIGNED NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_customer_id (customer_id),
INDEX idx_status_created (status, created_at)
);
Application code handles the cascade behaviour. When deleting a customer, the application explicitly soft-deletes (or hard-deletes) the customer's orders in a single transaction or background job.
Add a Vitess schema rules section to CLAUDE.md:
## Schema rules for Vitess
- PRIMARY KEY on every table, BIGINT UNSIGNED for high-growth tables
- NO FOREIGN KEY, ON DELETE CASCADE, or ON UPDATE CASCADE
- Index every column used in WHERE or JOIN
- Composite index for queries filtering on multiple columns: INDEX idx_a_b (col_a, col_b)
- Application enforces referential integrity (cascade deletes, orphan cleanup)
- Soft delete preferred over hard delete for tables with downstream references
- VARCHAR length matters: 32 for status enums, 255 for emails, TEXT for long content
- Use timestamps NOT NULL DEFAULT CURRENT_TIMESTAMP for created_at
- Use TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP for updated_at
Query patterns: serverless driver
The @planetscale/database driver speaks PlanetScale's HTTP protocol, which works on serverless and edge runtimes that cannot hold TCP connections open. The API mirrors mysql2 but every call is an HTTP request to PlanetScale's edge.
Basic SELECT:
// src/app/api/orders/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } },
) {
const result = await db.execute(
'SELECT id, customer_id, total_cents, status, created_at FROM orders WHERE id = ? LIMIT 1',
[params.id],
);
const order = result.rows[0];
if (!order) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(order);
}
Note the LIMIT 1 on a primary-key lookup. Even though the PK lookup returns at most one row, including LIMIT 1 makes the intent explicit and helps Vitess's query planner. Claude tends to omit LIMIT on PK lookups without an explicit CLAUDE.md rule.
Insert with returned ID:
import { db } from '@/lib/db';
interface CreateOrderInput {
customerId: string;
totalCents: number;
}
export async function createOrder({ customerId, totalCents }: CreateOrderInput) {
const result = await db.execute(
'INSERT INTO orders (customer_id, total_cents, status) VALUES (?, ?, ?)',
[customerId, totalCents, 'pending'],
);
return {
id: result.insertId,
rowsAffected: result.rowsAffected,
};
}
The insertId and rowsAffected fields are populated on every write. Always check rowsAffected > 0 after an UPDATE or DELETE to confirm the row existed.
Transaction:
import { db } from '@/lib/db';
export async function transferCredits(fromUserId: string, toUserId: string, amount: number) {
return db.transaction(async (tx) => {
await tx.execute(
'UPDATE users SET credits = credits - ? WHERE id = ? AND credits >= ?',
[amount, fromUserId, amount],
);
await tx.execute(
'UPDATE users SET credits = credits + ? WHERE id = ?',
[amount, toUserId],
);
await tx.execute(
'INSERT INTO credit_transfers (from_user_id, to_user_id, amount) VALUES (?, ?, ?)',
[fromUserId, toUserId, amount],
);
return { ok: true };
});
}
PlanetScale's HTTP driver supports transactions across multiple statements in a single keyspace. Cross-keyspace transactions are not supported. If your application spans keyspaces, structure transactions to fit within one.
Connection pooling for long-lived servers
If you are running on a traditional Node.js server (not edge), use mysql2 with a connection pool. The pool sits in front of PlanetScale's connection management and reuses TCP connections across requests.
// src/lib/db.ts
import mysql from 'mysql2/promise';
export const db = mysql.createPool({
uri: process.env.DATABASE_URL,
connectionLimit: 10,
waitForConnections: true,
queueLimit: 0,
// SSL is required on PlanetScale, the URI already specifies it
});
// Query helper that mirrors the @planetscale/database API
export async function query<T>(sql: string, params?: unknown[]): Promise<T[]> {
const [rows] = await db.execute(sql, params);
return rows as T[];
}
Set connectionLimit based on your PlanetScale plan's connection budget. The Scaler plan typically allows 1,000 concurrent connections per database. Divide by your application instance count and leave headroom for migrations and admin sessions. For a single Node.js instance, 10 to 20 connections is usually plenty.
Add connection driver selection to CLAUDE.md:
## Connection driver selection
| Runtime | Driver | Notes |
|-------------------------------|--------------------------|------------------------------------|
| Vercel Edge / Cloudflare | @planetscale/database | HTTP only, no TCP support |
| Vercel Serverless (Node) | @planetscale/database | HTTP avoids cold-start TCP delay |
| Long-lived Node.js server | mysql2 with pool | Pool reuses TCP connections |
| Local dev / scripts | @planetscale/database | Simpler, no pool to manage |
Choose ONE driver per runtime. NEVER mix in the same project.
Deploy request automation
CI/CD systems can create and review deploy requests programmatically. The pattern: a feature branch in Git triggers a matching PlanetScale branch and deploy request. When the Git PR is approved, the deploy request is also approved and deployed.
GitHub Actions example:
# .github/workflows/db-deploy.yml
name: PlanetScale Deploy Request
on:
pull_request:
paths:
- 'drizzle/migrations/**'
- 'prisma/migrations/**'
jobs:
create-deploy-request:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pscale
run: |
curl -fsSL https://github.com/planetscale/cli/releases/latest/download/pscale_linux_amd64.tar.gz | tar -xz
sudo mv pscale /usr/local/bin/
- name: Authenticate
run: pscale auth login --service-token-id ${{ secrets.PSCALE_TOKEN_ID }} --service-token ${{ secrets.PSCALE_SERVICE_TOKEN }}
- name: Create branch
run: pscale branch create your-db pr-${{ github.event.pull_request.number }}
- name: Apply migrations
run: |
DATABASE_URL=$(pscale connect your-db pr-${{ github.event.pull_request.number }} --execute 'echo $DATABASE_URL') \
npx drizzle-kit push:mysql
- name: Create deploy request
run: pscale deploy-request create your-db pr-${{ github.event.pull_request.number }}
Add CI/CD patterns to CLAUDE.md:
## CI/CD with PlanetScale
- One Git PR = one PlanetScale branch = one deploy request
- Branch name: pr-{number} for clear mapping
- Use service tokens for CI auth, NEVER user tokens
- Service tokens: PSCALE_TOKEN_ID + PSCALE_SERVICE_TOKEN in CI secrets
- Migrations apply to the branch in CI, deploy request opens automatically
- Human review on the deploy request, NOT on the migration file alone
- Merge the deploy request when the Git PR is approved AND tests pass on the branch
The deploy request review step is non-skippable in this workflow. PlanetScale's UI surfaces the schema diff in a format reviewers can read, including the gh-ost cutover plan. Claude generating migration code does not replace this review. The CLAUDE.md should make that explicit.
For more on integrating PlanetScale schema changes with a Next.js deployment pipeline, Claude Code with Vercel covers the preview environment patterns that pair with PlanetScale branches.
Common Claude Code mistakes with PlanetScale
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Foreign key constraints in schema
Claude generates: FOREIGN KEY (user_id) REFERENCES users(id) on every relationship.
Correct pattern: omit the constraint, add INDEX idx_user_id (user_id), enforce referential integrity in application code.
2. INT primary keys
Claude generates: id INT PRIMARY KEY AUTO_INCREMENT from generic MySQL tutorial training data.
Correct pattern: id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT for any table that might grow past a few million rows.
3. SELECT without LIMIT
Claude generates: SELECT * FROM orders ORDER BY created_at DESC for an "all orders" view.
Correct pattern: every SELECT has a LIMIT clause, even if the application paginates separately. LIMIT 1000 is a safety net.
4. Schema change applied directly to main
Claude generates: npx drizzle-kit push:mysql against the production DATABASE_URL.
Correct pattern: pscale branch create first, run migrations against the branch URL, open a deploy request.
5. Inline client instantiation
Claude generates: const db = connect({ url: process.env.DATABASE_URL }) inside every route handler.
Correct pattern: one singleton at src/lib/db.ts, imported across the project.
6. Mixing HTTP and TCP drivers
Claude generates: @planetscale/database in some files and mysql2 in others within the same Next.js project.
Correct pattern: pick one driver based on runtime, use it consistently. Edge gets HTTP, long-lived server gets mysql2 pool.
Add a common mistakes section to CLAUDE.md with these six pairs as concrete before/after examples.
Permission hooks for database scripts
A PlanetScale project accumulates scripts: seed scripts, data migrations, backfills, branch cleanup utilities. Some are read-only. Some delete data. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pscale branch list*)",
"Bash(pscale branch create*)",
"Bash(pscale shell*)",
"Bash(pscale deploy-request list*)",
"Bash(pscale connect*)",
"Bash(node scripts/seed-dev.js*)"
],
"deny": [
"Bash(pscale branch delete*)",
"Bash(pscale deploy-request deploy*)",
"Bash(node scripts/purge-orders.js*)",
"Bash(node scripts/truncate-table.js*)"
]
}
}
Branch creation and deploy request creation are reversible. Branch deletion and deploy execution are not. The deny list forces Claude to surface these as explicit prompts.
Building schemas that ship safely
The PlanetScale CLAUDE.md in this guide produces database integrations where every schema change goes through a branch and deploy request, foreign keys are replaced with application-layer enforcement, primary keys use BIGINT UNSIGNED, every SELECT carries a LIMIT, the singleton client is imported rather than re-instantiated, and the driver matches the runtime.
The underlying principle: PlanetScale's Vitess foundation has hard constraints that vanilla MySQL does not, and Claude's training data is mostly vanilla MySQL. The CLAUDE.md template makes the Vitess constraints explicit so Claude generates correct schemas the first time. Every rule you skip is a future production incident.
For the next layer up, PlanetScale pairs well with Claude Code with Drizzle for type-safe queries and Claudify includes a PlanetScale-specific CLAUDE.md template with branch workflow commands, Vitess schema rules, driver selection logic, and all six common-mistake patterns pre-configured.
Get Claudify. Ship PlanetScale schemas that respect Vitess on the first try.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify