← Back to Blog

Ship risky schema changes with per-branch databases

Part 11 of 12 in the Prisma Compute series.View full series →

Give every branch its own database, and risky schema changes become routine. Prisma Compute provisions an isolated Prisma Postgres database for each branch you deploy with its --db flag. Migrations from different pull requests stop colliding, and a destructive change gets a full rehearsal of its migration sequence on real infrastructure before it touches production.

This post walks through the workflow: why a shared staging database breaks under concurrent schema changes, how per-branch databases remove the conflict, and how to rehearse a destructive change end-to-end with the expand-and-contract pattern.

Why a shared staging database breaks

A shared staging database has one state. Concurrent branches need different ones. When two developers change the schema in the same sprint, they collide:

  1. Alice branches off main, adds a displayName column, and migrates the shared staging database.
  2. Bob branches off main, removes a legacy name column, and migrates the same database.
  3. Alice merges first. Her migration becomes the new baseline on main.
  4. Bob tries to merge. The staging database now reflects Alice's schema, not the one his migration was written against. His migration fails, or worse, half-applies.
Alice branches off main Migration: add displayName Bob branches off main Migration: remove name Alice merges first Staging DB now has displayName Bob tries to merge Migration fails: database state doesn't match

Prisma Next migrations make this failure precise instead of silent: every migration records the contract state it starts from and the state it moves to, and the runner refuses to apply a migration against a database in the wrong state. Bob gets a hard error instead of a corrupted staging environment.

The conventional workarounds all paper over the same root cause:

  • Rebase and replan: Bob replans his migration against Alice's baseline and hopes it still does what he intended. Works for additive changes, gets fragile with data transformations.
  • Reset staging on every merge: re-apply every migration from scratch. Technically sound, but it destroys the data your integration tests rely on.
  • Sequential merges: only one person touches the schema at a time. The de facto standard, and the slowest option.

The root cause is one database serving multiple migration timelines, so the fix is one database per timeline.

One database per branch

On Prisma Compute, a branch is a real resource that owns its own app and its own database. Deploy a branch name that doesn't exist yet and Compute provisions an isolated environment for it:

You

Skillprisma-cli
Agent running prisma-cli
$
Deploying my-app / remove-legacy-name / web
◇ Creating branch database…
✔ Created branch database
✔ Added branch env: DATABASE_URL
Building locally… Built
Uploading… Uploaded
Deploying… Deployed
✔ Live in 4.2s
https://remove-legacy-name.my-app.prisma.build

Alice's branch gets a database with her schema. Bob's branch gets a database with his. Neither can break the other, and neither needs to rebase until merge time. When Bob does rebase after Alice merges, he replans against the updated contract and tests the result on his own branch database first.

Alice's branch Bob's branch Alice merges to main Bob rebases, replans, merges contract.prisma migration plan migrate Alice's DB contract.prisma migration plan migrate Bob's DB

Isolation solves the concurrency problem. It also gives you a safe place to rehearse changes that are dangerous by nature: expand-and-contract migrations.

Rehearse the risky change: expand and contract

Renaming a column, changing a type, dropping a field that production code still reads: one big migration for any of these breaks whatever is running while it applies. The safe version splits the change into three small, individually harmless migrations:

  1. Expand: add the new schema next to the old. Both code paths work.
  2. Migrate: backfill data from old to new, inside a migration.
  3. Contract: remove the old schema once nothing reads it.

The pattern is well known and rarely practiced, because rehearsing a three-migration sequence on a shared database is exactly the kind of multi-deploy work that collides with everyone else. On a branch database, you run the whole cycle in isolation, verify each step, and only then promote it to production.

Here's the cycle for replacing a legacy name column with displayName: the same two columns from the Alice and Bob story, now done properly as one deliberate sequence on one branch.

Step 1: Expand

Add displayName as nullable, so existing code that only knows about name keeps working:

You

Skillprisma-next-migrations
contract.prisma
Editing contract.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String
@@map("user")
}
Agent running prisma-next-migrations
$
✔ Planned 1 operation(s)
└─ Add column "displayName" to "user"
DDL preview
ALTER TABLE "public"."user" ADD COLUMN "displayName" text;

The planner turns the contract diff into a TypeScript migration:

// migrations/app/20260724T1000_add_display_name/migration.ts (excerpt)

override get operations() {
  return [
    this.addColumn({
      schema: "public",
      table: "user",
      column: col("displayName", "text", { codecRef: { codecId: "pg/text@1" } }),
    }),
  ];
}

Apply it to your branch database with npx prisma-next migrate and deploy the branch. Old code still reads name; new code can start writing displayName.

Step 2: Migrate

Next, make displayName required in the contract:

// contract.prisma
model User {
  id          Int    @id @default(autoincrement())
  email       String @unique
  displayName String // was String?
  name        String // still here until step 3

  @@map("user")
}
npx prisma-next migration plan --name require_display_name

The planner can write the SET NOT NULL itself, but only you know what existing rows should contain, so it scaffolds a backfill placeholder in front of the constraint and leaves the query to you. The backfill lives inside the migration as a dataTransform, written with the same type-safe query builder you use in your app, instead of a standalone script that runs outside your migration history. Filled in:

// migrations/app/20260724T1100_require_display_name/migration.ts (excerpt)

override get operations() {
  return [
    this.dataTransform(endContract, "backfill-user-displayName", {
      check: () =>
        db.public.user
          .select("id")
          .where((f, fns) => fns.eq(f.displayName, null))
          .limit(1),
      run: () =>
        db.public.user
          .update((f, fns) => ({
            displayName: fns.raw`COALESCE(${f.name}, 'Anonymous')`.returns("pg/text@1"),
          }))
          .where((f, fns) => fns.eq(f.displayName, null)),
    }),
    this.setNotNull({ schema: "public", table: "user", column: "displayName" }),
  ];
}

check asks "are there rows that still need this?" and run performs the change, here copying each row's name into displayName. Every migration folder carries a snapshot of the contract as it stood at that point in history, and both queries are typed against this migration's snapshot rather than your live schema. That's why the query can reference name even though a later migration drops it.

After editing, recompile with node migration.ts. The dataTransform compiles to the same precheck / execute / postcheck JSON as every other operation: reviewers read your intent in migration.ts and the exact SQL in ops.json, and production only ever runs the compiled JSON, never your TypeScript. See Editing a migration for the full file, including the wiring that builds the typed db handle.

Run it against the branch database and check the data. Seed the branch database with realistic data first; an empty database proves the sequence applies cleanly, but only a real-shaped dataset tells you anything about the backfill. Nothing has touched production yet.

Step 3: Contract

Once all code reads displayName and the backfill is verified, remove the old column:

You

Skillprisma-next-migrations
contract.prisma
Editing contract.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
displayName String
name String // no code reads this anymore
@@map("user")
}
Agent running prisma-next-migrations
$
✔ Planned 1 operation(s)
└─ Drop column "name" from "user" (destructive)
⚠ This migration contains destructive operations that may cause data loss.
DDL preview
ALTER TABLE "public"."user" DROP COLUMN "name";

Plan, apply, deploy. Three small migrations, and the whole sequence rehearsed on a branch database before production sees any of it.

1. Expand: add displayName, nullable 2. Migrate: backfill, then require 3. Contract: drop name

Roll forward, not back

Rollbacks are the wrong mental model for schema changes, and that's why the rehearsal matters.

Promoting an earlier deployment on Compute restores your app code, not your schema; the database keeps the state from the most recent migration. On the schema side, Prisma Next has no migrate down and no down-migration files. Rolling back means planning one more migration to a state your database has already been in: the planner diffs the two states, writes the operations that undo the change, and flags destructive ones with a data-loss warning so you can edit the plan before applying it. History stays append-only, git revert rather than git reset, and no rollback can resurrect the data a dropped column held.

Expand-and-contract keeps every forward step small, and per-branch databases give you a place to rehearse the sequence until it's boring.

Automate it in CI

Once the manual workflow feels routine, wire it into your pipeline. The sketch below deploys the branch first, with --db so a fresh branch gets its database provisioned, then runs migrations and tests against that database:

# .github/workflows/branch-preview.yml
name: Branch preview

on:
  pull_request:

jobs:
  deploy-preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 24

      - run: npm ci

      - name: Deploy the branch to Compute
        run: |
          npx @prisma/cli@latest app deploy \
            --project my-app \
            --app web \
            --branch "$GITHUB_HEAD_REF" \
            --db \
            --json \
            --no-interactive
        env:
          PRISMA_SERVICE_TOKEN: ${{ secrets.PRISMA_SERVICE_TOKEN }}

      - name: Apply migrations to the branch database
        run: npx prisma-next migrate --db "$DATABASE_URL"
        env:
          DATABASE_URL: ${{ secrets.BRANCH_DB_URL }}

      - name: Integration tests against the branch database
        run: npm test
        env:
          DATABASE_URL: ${{ secrets.BRANCH_DB_URL }}

One piece of wiring is yours to choose: how the migrate and test steps get the branch database's connection string (BRANCH_DB_URL above). A single repository secret only serves one branch at a time; for concurrent pull requests, have the job create a connection for its own branch database with npx @prisma/cli@latest database connection create <database> using the service token it already holds. Keep any stored value in your own secret store; Compute's environment variables are write-only in beta, so CI can't read them back.

If you connect the project to GitHub, Compute also creates and tears down platform branches on push and branch-delete events automatically.

When you don't need this

If you're a solo developer, or your team rarely touches the schema, shared staging is fine. The conflict rate is low enough that an occasional rebase doesn't hurt.

Per-branch databases earn their keep when:

  • multiple developers ship schema changes in the same sprint,
  • you run expand-and-contract migrations that span multiple deploys,
  • your CI runs integration tests against a real database, or
  • a migration conflict has ever blocked a release.

Frequently asked questions

Try it yourself

Prisma Next is in Early Access. It isn't production-ready yet; Prisma 7 remains the right choice for production today. But the per-branch workflow is ready to try now:

npx prisma-next@latest init

Write a contract, plan a migration, and read the migration.ts and ops.json it produces. Then, from a feature branch, deploy to Compute with npx @prisma/cli@latest app deploy --db. The CLI targets your current Git branch, so that one command gives the branch its own database, ready for the expand-and-contract cycle.

Tell us what worked and what didn't on Discord in the #prisma-next channel, and star prisma/prisma-next on GitHub to follow development.

About the authors

Shane Neubauer
Shane Neubauer

Shane is a product leader at Prisma with more than 15 years in technology, including five years prototyping new products at Google and founding a venture-backed startup of his own. He writes about product strategy, go-to-market, and building tools developers genuinely want to use.

Ankur Datta
Ankur Datta

Ankur is a member of the Prisma team who works closely with the developer community, with hundreds of contributions across Prisma's open source repositories and a background that includes founding an ed-tech startup. He writes about TypeScript, Node.js, PostgreSQL, and modern application stacks.

Build your next app with Prisma

Start free. Scale when you’re ready.

Try Prisma
Share this article