Published on

Database Branching — Git-Like Workflows for Your Schema

Authors

Introduction

Git changed software development. Before git, merging code was painful and error-prone. After git, branches are cheap and merging is routine.

Database branching brings the same paradigm to databases. Every PR gets its own database. Test migrations safely. Review schema changes before merge. Never worry about shared test databases again.

This capability, unthinkable a decade ago, is now standard (Neon, PlanetScale). It''s transforming how teams develop.

This post covers database branching strategies, workflows, and automation.

What Is Database Branching?

Database branching creates a copy of the database for isolated testing.

Traditional workflow:

  1. Develop feature (local)
  2. Push to staging (shared)
  3. Run migrations on staging (affects all developers)
  4. Test (pray others aren''t breaking it)
  5. Deploy to prod

New workflow (with branching):

  1. Develop feature (local)
  2. Create database branch (copy of prod schema/data)
  3. Push feature branch (uses database branch)
  4. Automated testing on PR (with isolated database)
  5. Merge PR (database branch deleted, automatic)

No shared staging database. No "sorry, I broke staging." No "we can''t test, schema is changing."

Neon Branch-Per-PR Workflow

Neon makes branching effortless. Every PR gets a database branch automatically.

Setup in GitHub Actions:

name: Create Database Branch

on:
  pull_request:
    types: [opened]

jobs:
  create-branch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Create Neon branch
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
        run: |
          BRANCH_ID=$(curl -X POST https://api.neon.tech/v1/projects/$NEON_PROJECT_ID/branches \
            -H "Authorization: Bearer $NEON_API_KEY" \
            -H "Content-Type: application/json" \
            -d '{
              "branch": {
                "parent_id": "main",
                "name": "pr-${{ github.event.pull_request.number }}"
              }
            }' | jq -r '.branch.id')

          echo "BRANCH_ID=$BRANCH_ID" >> $GITHUB_OUTPUT

      - name: Comment on PR
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: 'Database branch created: `pr-${{ github.event.pull_request.number }}`\n\nPreview: https://console.neon.tech/...'
            })

  cleanup:
    runs-on: ubuntu-latest
    needs: create-branch
    steps:
      - name: Delete branch on close
        if: github.event.action == 'closed'
        run: |
          curl -X DELETE https://api.neon.tech/v1/projects/$NEON_PROJECT_ID/branches/$BRANCH_ID \
            -H "Authorization: Bearer $NEON_API_KEY"

Now, preview deployments automatically use the PR''s database branch. No setup, no manual steps.

PlanetScale''s Branching Model

PlanetScale (MySQL) also supports branching. Different from Neon but equally powerful.

# Create a branch from main
pscale branch create my-db dev-feature-123

# List branches
pscale branch list my-db

# Apply migrations to branch
pscale deploy-request create my-db dev-feature-123

# Review and merge
pscale deploy-request review my-db <id>
pscale deploy-request deploy my-db <id>

PlanetScale branches are lighter-weight (don''t copy all data). Schema changes are queued in "deploy requests." Review before deploying.

Workflow:

  1. Create branch
  2. Write migration
  3. Create deploy request
  4. Review (schema diff visible)
  5. Deploy (automatic, zero-downtime)

Safer than Neon (explicit approval), but slower (queued deployments).

Testing Migrations Safely in Branches

Migrations are risky. A slow migration locks tables. A bad schema change breaks queries.

With branches, test migrations for free:

// migration.sql
ALTER TABLE users ADD COLUMN email_normalized VARCHAR(255);
CREATE INDEX idx_users_email_normalized ON users(email_normalized);

-- Test: run this migration on a branch
-- Verify it completes in &lt;1 second (no locks)
-- Verify no queries fail
-- Then deploy to production

In CI, run tests against the branch:

name: Test Migrations

on: [pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Run migrations on branch
        env:
          DATABASE_URL: ${{ secrets.NEON_BRANCH_URL }}
        run: npx drizzle-kit migrate:postgres

      - name: Run test suite
        env:
          DATABASE_URL: ${{ secrets.NEON_BRANCH_URL }}
        run: npm test

      - name: Benchmark query performance
        env:
          DATABASE_URL: ${{ secrets.NEON_BRANCH_URL }}
        run: npm run benchmark

If tests fail, the branch is rolled back automatically. Production is never touched.

Schema Diff Before Merge

With branches, you can show the exact schema changes before merging.

Neon API:

curl -X GET https://api.neon.tech/v1/projects/$NEON_PROJECT_ID/branches/$BRANCH_ID/operations \
  -H "Authorization: Bearer $NEON_API_KEY" | jq '.operations[] | select(.type == "migrations")'

PlanetScale deploy request shows diff:

pscale deploy-request view my-db <id> --json

Output:

{
  "from_schema": "CREATE TABLE users (...)",
  "to_schema": "CREATE TABLE users (...), CREATE TABLE posts (...)",
  "statements": [
    "CREATE TABLE posts (id INT PRIMARY KEY, user_id INT REFERENCES users(id))"
  ]
}

Reviewers see exactly what changed. No surprises.

Reverting a Bad Migration

With branches, reverting is trivial.

If a production migration causes issues:

# Neon: revert branch to previous state
neon branches reset --name main --to-commit <commit-hash>

# Or, create a reverse migration
ALTER TABLE users DROP COLUMN email_normalized;
DROP INDEX idx_users_email_normalized;

Deploy the reverse migration. Done in seconds, no downtime.

Without branches, reverting is stressful and time-consuming.

Automating Branch Creation in CI

Completely automate the workflow:

name: Database Workflow

on: [pull_request, push]

jobs:
  create-or-cleanup:
    runs-on: ubuntu-latest
    outputs:
      branch_url: ${{ steps.create.outputs.branch_url }}
    steps:
      - uses: actions/checkout@v3

      - name: Create branch (PR opened)
        if: github.event_name == 'pull_request' && github.event.action == 'opened'
        id: create
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
        run: |
          RESPONSE=$(curl -X POST https://api.neon.tech/v1/projects/$NEON_PROJECT_ID/branches \
            -H "Authorization: Bearer $NEON_API_KEY" \
            -H "Content-Type: application/json" \
            -d '{
              "branch": {
                "parent_id": "main",
                "name": "pr-${{ github.event.pull_request.number }}"
              }
            }')

          BRANCH_URL=$(echo $RESPONSE | jq -r '.branch.connection_uri')
          echo "branch_url=$BRANCH_URL" >> $GITHUB_OUTPUT

      - name: Delete branch (PR closed)
        if: github.event_name == 'pull_request' && github.event.action == 'closed'
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
        run: |
          curl -X DELETE https://api.neon.tech/v1/projects/$NEON_PROJECT_ID/branches/pr-${{ github.event.pull_request.number }} \
            -H "Authorization: Bearer $NEON_API_KEY"

  test:
    needs: create-or-cleanup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Run migrations
        env:
          DATABASE_URL: ${{ needs.create-or-cleanup.outputs.branch_url }}
        run: npx drizzle-kit migrate:postgres

      - name: Run tests
        env:
          DATABASE_URL: ${{ needs.create-or-cleanup.outputs.branch_url }}
        run: npm test

      - name: Deploy to Vercel
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
        run: vercel deploy --prod --token=$VERCEL_TOKEN

Every PR:

  1. Creates a database branch (automatic)
  2. Runs migrations (automatic)
  3. Runs tests against branch (automatic)
  4. Deploys preview to Vercel (automatic)
  5. Deletes branch on close (automatic)

No manual steps. No human error.

Branch Cleanup Automation

Branches cost money. Forgotten branches add up.

Cleanup policy:

# Delete branches older than 7 days
pscale branch list my-db --format json | \
  jq '.[] | select(.created_at < now - 7 days) | .name' | \
  xargs -I {} pscale branch delete my-db {}

Or, configure automatic cleanup:

name: Cleanup Old Branches

on:
  schedule:
    - cron: '0 0 * * 0'  # Weekly

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Delete branches &gt; 7 days old
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
        run: |
          BRANCHES=$(curl -X GET https://api.neon.tech/v1/projects/$NEON_PROJECT_ID/branches \
            -H "Authorization: Bearer $NEON_API_KEY" | jq '.branches[]')

          echo "$BRANCHES" | jq -r '.name' | while read branch; do
            CREATED=$(echo "$BRANCHES" | jq -r --arg b "$branch" '.[] | select(.name == $b) | .created_at')
            AGE_DAYS=$(( ($(date +%s) - $(date -d "$CREATED" +%s)) / 86400 ))

            if [ $AGE_DAYS -gt 7 ]; then
              echo "Deleting branch: $branch (age: ${AGE_DAYS} days)"
              curl -X DELETE https://api.neon.tech/v1/projects/$NEON_PROJECT_ID/branches/$branch \
                -H "Authorization: Bearer $NEON_API_KEY"
            fi
          done

Automatic cleanup prevents surprise bills.

Seeding Test Data in Branches

Each branch starts as a copy of production. For testing, populate with test data:

// scripts/seed-branch.ts
import { neon } from '@neondatabase/serverless';

const sql = neon(process.env.DATABASE_URL);

export async function seedBranch() {
  // Only seed if branch (check for marker)
  const isBranch = await sql`
    SELECT EXISTS(
      SELECT 1 FROM information_schema.tables
      WHERE table_schema = 'public' AND table_name = '_branch_marker'
    );
  `;

  if (!isBranch[0].exists) {
    console.log('Not a branch, skipping seed');
    return;
  }

  // Insert test users
  await sql`
    INSERT INTO users (id, email, name) VALUES
    (gen_random_uuid(), 'test@example.com', 'Test User'),
    (gen_random_uuid(), 'admin@example.com', 'Admin')
  `;

  // Insert test posts
  await sql`
    INSERT INTO posts (id, user_id, title) VALUES
    (1, (SELECT id FROM users WHERE email = 'test@example.com'), 'Test Post')
  `;

  console.log('Seeded branch with test data');
}

Run in CI after creating branch:

- name: Seed branch
  env:
    DATABASE_URL: ${{ secrets.NEON_BRANCH_URL }}
  run: npx ts-node scripts/seed-branch.ts

Now your preview environment has realistic test data without affecting production.

Comparing Database Branching Tools

ToolModelCostZDDAPI
NeonFull DB copyPer branchREST
PlanetScaleSchema onlyPer deploy requestCLI
PlanetfallLightweightPer branchUnknown
SupabaseN/AN/AN/AN/A

Neon: most flexible, lightweight cost. Good for small branches.

PlanetScale: explicit approval workflow, zero-downtime deploys, MySQL-only.

Neither is perfect. Neon is easier to automate. PlanetScale has safer workflows.

When Branching Is Overkill

Branching adds complexity. For small teams or simple schemas, it might not be worth it.

Don''t use branching if:

  • Small team (1–3 engineers)
  • Simple schema (<10 tables)
  • Rare migrations (monthly)
  • Low risk tolerance (prefer simplicity)

In these cases, a single shared staging database is acceptable. Coordinate migrations carefully.

Use branching if:

  • Growing team
  • Complex schema
  • Frequent migrations
  • Multiple teams (avoid merge conflicts)

Real-World Example: Feature Branch Workflow

Engineer pushes feature branch
GitHub Actions triggered
Neon branch created (pr-123)
Migrations run (drizzle-kit migrate)
Tests run (npm test)
Benchmarks run (query perf checked)
Preview deployed (Vercel with isolated DB)
QA tests preview
PR approved and merged
Neon branch deleted (automatic)
Production deployment (uses main branch)
Success!

Entire flow is automated. Zero manual database steps. Schema changes are proven before production.

This is the future of database development.

Checklist

  • Choose branching tool (Neon or PlanetScale)
  • Set up GitHub Actions for auto-branching
  • Create test suite that runs on branches
  • Set up schema diff visibility in PRs
  • Automate branch cleanup (weekly)
  • Seed test data in branches
  • Document branch workflow for team
  • Test migration rollback
  • Monitor branch costs
  • Integrate with Vercel/deployment system

Conclusion

Database branching is no longer experimental. It''s the best practice for database development.

Neon and PlanetScale have made it accessible. Automated CI integration means zero manual overhead.

The benefits are substantial: safer migrations, faster feedback, no shared database contention, and confidence in schema changes.

If you''re still using shared staging databases, branching is a step up. Adopt it. Your team will thank you.