Skip to main content
Database migration changes the shape of your tables (columns, types, constraints). Data migration moves or transforms the existing rows so they survive that shape change intact. A schema migration completing without an error does not mean no data was lost — Prisma will happily drop a column, narrow a type, or add a NOT NULL field with a default, silently discarding whatever was there before if you don’t handle it explicitly. No data loss is acceptable at any environment level. Dev and local work from the same backup — none of it is throwaway data.

The core rule

Before running any migration — on a PR branch, dev VPS, or prod VPS — always read the migration warning output first. Prisma prints an explicit warning when an operation is potentially destructive: dropping a column, dropping a table, removing a unique constraint, narrowing a type, or adding a required field without a safe default. If you see one of these, stop and write a data migration plan before applying it — don’t just re-run and ignore the warning.
Know which environment you’re touching — but note that “low risk” does not mean data loss is acceptable anywhere in this pipeline:
  • PR / local schema — the developer works from the same backup the dev-responsible person uses on the dev VPS. This is not throwaway scratch data; any data loss here reflects a real problem that will resurface on dev. Treat warnings with the same seriousness as you would on dev.
  • Dev VPS — shared team data restored from the same backup. No data loss is acceptable — confirm every warning is resolved before applying.
  • Prod VPS — real user data. Treat every warning here as a hard stop until resolved.
Every breaking schema change is split into three separate Prisma migrations, in this exact order:
  1. Expand — add the new column/table. Nullable, no constraints. Old code keeps working.
  2. Backfill — a pure SQL migration that copies/transforms data from the old shape into the new shape.
  3. Contract — drop the old column, add NOT NULL/unique constraints, etc. Only safe once backfill has run. Because all three are standard Prisma migrations, prisma migrate deploy runs them in one batch, in folder-timestamp order — expand, then backfill, then contract, every time. There is no separate runner, no external script, and no risk of contract dropping data before backfill has a chance to copy it.
Never let a contract migration (drop column / add NOT NULL) sit in the same batch with an earlier timestamp than its backfill migration. Since prisma migrate deploy executes strictly by folder name order, getting this order wrong means data loss with no warning.

Why pure SQL instead of a TS script

Data migrations used to be plain TypeScript files run through tsx, tracked in a DataMigration table, and wired into db:bootstrap:* after the schema migration. That approach has one structural problem: it runs the TS runner as a separate step after prisma migrate deploy completes. If a contract migration lands in the same prisma migrate deploy batch as its matching backfill, the contract migration (SQL, run by Prisma) executes before the TS script (run afterward) ever gets a chance to backfill the data. Writing the backfill as a standard Prisma migration removes that gap entirely — it becomes just another migration.sql file, so it’s guaranteed to run in its correct position in the sequence, with no external orchestration.

How to write a backfill migration

1. Generate an empty migration

2. Write the transform as pure SQL

Open the generated migration.sql and write the transform directly — no JS, no Prisma Client calls. Simple case (single UPDATE/INSERT, no row-by-row logic needed):
Every migration file must be wrapped in its own BEGIN; ... COMMIT; block. This guarantees the transform is all-or-nothing — if any statement in the file fails, the whole migration rolls back instead of leaving data half-migrated. Prisma already wraps each migration in a transaction by default, but wrap it explicitly anyway so the file is correct even if run manually outside Prisma (e.g. directly against prod with psql during an incident).

3. Complex logic — use PL/pgSQL, not a TS loop

Postgres handles more than it looks like at first. Row-by-row logic, conditionals, and validation can all live inside the migration as a FUNCTION or PROCEDURE:
What PL/pgSQL supports inside a migration:
  • Loops (FOR, WHILE, LOOP) and conditionals (IF/ELSIF/ELSE, CASE)
  • Variables, records, arrays
  • Exception handling (BEGIN ... EXCEPTION WHEN ... END)
  • Dynamic SQL (EXECUTE), recursive CTEs, jsonb manipulation
  • Chunked/batched updates (LIMIT/OFFSET loops) to avoid long locks The one hard boundary: PL/pgSQL cannot make outbound HTTP calls or call external APIs. Everything else — conditional transforms, per-row processing, validation — is doable in SQL.

4. Control execution order

Prisma applies migrations in alphanumeric folder-name order. make sure to land it like this:

Full example: moving a field from one table to another

Scenario: move phone_number from users to a new user_contacts table. Migration 1 — Expand (20260719000001_expand_user_contacts/migration.sql)
Migration 2 — Backfill (20260719000002_backfill_user_contacts/migration.sql)
Migration 3 — Contract (20260719000003_contract_users_phone/migration.sql)
Why this order is safe: migration 1 only adds a table (old code unaffected), migration 2 copies data over while the old column still exists, migration 3 only drops the old column once the copy is confirmed complete — and all three are guaranteed to run in this order in one prisma migrate deploy.

Batching for large tables

A single-statement backfill is fine for small-to-medium tables. For millions of rows, batch it — a single multi-million-row transaction holds locks too long and bloats the table (MVCC keeps old row versions around until commit).
A PROCEDURE (not a FUNCTION) is required for batching, because only a procedure called via CALL can issue an intermediate COMMIT inside its loop. A FUNCTION runs as one transaction start to finish.
Don’t batch with LIMIT/OFFSET. Each iteration re-scans and discards every row before its offset, so the query gets slower with every batch — by the time you’re at offset 5 million, Postgres scans 5 million rows just to find the next 5,000. Use cursor (keyset) pagination instead: chunk by id range, so every batch hits the index directly and stays the same speed from the first row to the last.
Each iteration filters on id, which is indexed (primary key), so every batch is a fast index range scan — no re-scanning already-processed rows, no slowdown as the loop progresses. Rule of thumb: Batching isn’t free — it trades speed for reduced lock time. If the table is small or there’s no concurrent prod traffic to protect, skip it; the loop/commit overhead makes batching slower than a single UPDATE for that case.

For a table drop

Don’t rely on a backfill migration alone. Take a full backup of the table before the contract migration runs — there’s no “expand” step for a dropped table, so the backup is the only safety net. Only write a restore script later, if it turns out the data is actually needed.

Adding a new data migration — checklist

  1. Generate the migration: npx prisma migrate dev --create-only --name backfill_<description>.
  2. Write the transform as pure SQL, wrapped in BEGIN; ... COMMIT; (or a batched PROCEDURE for large tables).
  3. Confirm folder timestamps place it strictly between its expand and contract migrations.
  4. Test locally: pnpm db:bootstrap:local.
  5. Commit the expand migration, the backfill migration, and the contract migration together in the same PR.

Mandatory communication

  • Developer → Reviewer: Call out explicitly in the PR description that a data migration exists, what it does, and which environments it affects.
  • Reviewer → Prod-responsible: State explicitly whether the data migration ran as part of the release, what it did on dev, and any anomalies. “It worked on dev” is not a handoff.

Checklist before merging a migration with data-loss risk

  • Read Prisma’s migration warning in full — don’t skip past it
  • Confirm whether the affected column/table/field currently holds real data (no environment is exempt — see above)
  • If yes: write the expand → backfill → contract plan, add the data migration using npx prisma migrate dev --create-only --name
  • Test the full bootstrap (pnpm db:bootstrap:local) against a real backup, not an empty local DB
  • Get explicit reviewer sign-off specifically on the data-migration step, separate from normal code review